Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison ed90426d96 docs: enhance CONTRIBUTING.md with setup instructions and testing guidance
- Add detailed installation steps with pip install commands
- Specify PEP 8 as code style standard
- Include pytest test running instructions
- Add guidance to check/create issues for large changes
- Include link to GitHub Issues for support

Addresses feedback from code review bots (CodeRabbit, Gemini, Greptile)

Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 18:34:13 +00:00
Akhil Chalasani 9c32b9d84c docs: improve documentation
Created CONTRIBUTING.md
2026-05-09 13:22:09 -05:00
191 changed files with 6413 additions and 12755 deletions

No files matched your search

-51
View File
@@ -1,51 +0,0 @@
# PraisonAI Environment Variables
# Copy this file to .env and update with your actual values
# === Required ===
OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com
# === Required for Claw web-search tool ===
TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available)
# === Optional: Claw dashboard security ===
PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk)
# === Optional: Alternative LLM Providers ===
ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models
GOOGLE_API_KEY=... # Google Gemini models
GROQ_API_KEY=gsk_... # Groq (fast inference)
COHERE_API_KEY=... # Cohere models
AZURE_OPENAI_API_KEY=... # Azure OpenAI
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
# === Optional: Memory backends ===
MEM0_API_KEY=... # Mem0 memory service
REDIS_URL=redis://localhost:6379 # Redis for state management
# === Optional: Observability ===
LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing
LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key
# === Optional: Bot platforms ===
TELEGRAM_BOT_TOKEN=... # Telegram bot token
DISCORD_BOT_TOKEN=... # Discord bot token
SLACK_BOT_TOKEN=xoxb-... # Slack bot token
SLACK_APP_TOKEN=xapp-... # Slack app token
WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token
WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
# === Optional: Database ===
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
# === Optional: Tool Security ===
# ALLOWED_TOOLS=search,send_message # Whitelist specific tools to prevent name collisions
# Useful in multi-environment setups with overlapping tool names
# Leave unset to use all tools (with collision warnings)
# Do NOT set to empty string: that's treated as configuration error
# In CI, unknown tool names in the whitelist cause strict startup failure
# HERMES_ONLY_TOOLS=search,send_message # (Deprecated) Backward compatibility alias for ALLOWED_TOOLS
# === Optional: Development ===
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
DEBUG=false # Enable debug mode
+2 -5
View File
@@ -181,15 +181,12 @@ jobs:
- name: Fetch PR branch and Setup Remote
if: github.event.issue.pull_request
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
run: |
# Fetch PR head from base repo and put it into local branch
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${IS_FORK}" = "true" ]; then
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
git remote set-url origin file://$(pwd)
fi
+5 -4
View File
@@ -29,6 +29,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
fetch-depth: 0
- name: Generate GitHub App Token
@@ -60,12 +63,10 @@ jobs:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PRAISONAI_AUTO_APPROVE: "true"
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
export ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
export ISSUE_NUMBER="${{ inputs.issue_number }}"
else
export ISSUE_NUMBER="$ISSUE_NUMBER_EVENT"
export ISSUE_NUMBER="${{ github.event.issue.number }}"
fi
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
+6 -7
View File
@@ -30,23 +30,22 @@ jobs:
steps:
- name: Determine checkout ref
id: dest
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ "${{ github.event_name }}" = "issue_comment" ]; then
echo "ref=refs/pull/$ISSUE_NUMBER/head" >> "$GITHUB_OUTPUT"
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ref=refs/pull/$PR_NUMBER_INPUT/head" >> "$GITHUB_OUTPUT"
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
else
echo "ref=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout Repository
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
ref: ${{ steps.dest.outputs.ref }}
fetch-depth: 0
+2 -14
View File
@@ -26,8 +26,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
@@ -176,18 +176,6 @@ jobs:
run: praisonai queue list
continue-on-error: true
# ==================== PACKAGING TESTS ====================
- name: Test packaging/daemon entry points
run: |
echo "Testing both entry point methods..."
python -m praisonai --version
praisonai --version
- name: Test doctor packaging command
run: |
echo "Testing doctor packaging diagnostics..."
praisonai doctor packaging --json
# ==================== REAL API TESTS ====================
- name: Real API test - Agent run
if: env.OPENAI_API_KEY != ''
+29
View File
@@ -0,0 +1,29 @@
# Contributing to PraisonAI
Thank you for your interest! Here's how to get started:
## Getting Started
1. Fork the repository
2. Clone your fork and install dependencies:
```bash
git clone https://github.com/your-username/PraisonAI.git
cd PraisonAI
pip install -e src/praisonai-agents/
pip install -e src/praisonai/
```
3. Create a feature branch: `git checkout -b feature/your-feature`
4. Make your changes
5. Submit a pull request
## Guidelines
- Follow existing code style (PEP 8)
- Add tests for new features and run them locally using pytest:
```bash
cd src/praisonai-agents && pytest tests/
```
- Update documentation as needed
- Check existing issues or open a new one to discuss your idea before implementing large changes
For questions or support, please visit our [GitHub Issues](https://github.com/MervinPraison/PraisonAI/issues).
+2 -39
View File
@@ -294,35 +294,8 @@ def search(query: str) -> str:
@tool
def calculate(expression: str) -> float:
"""Safely evaluate a numeric arithmetic expression."""
import ast
import operator
# Define allowed operations
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
else:
raise ValueError("Unsupported expression")
try:
return _safe_eval(ast.parse(expression, mode="eval").body)
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
raise ValueError("Invalid arithmetic expression")
"""Evaluate a math expression."""
return eval(expression)
agent = Agent(
instructions="You are a helpful assistant",
@@ -331,7 +304,6 @@ agent = Agent(
agent.start("Search for AI news and calculate 15*4")
```
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
### 5. Persistence (Databases)
@@ -358,15 +330,6 @@ pip install "praisonai[claw]"
praisonai claw
```
#### Required Environment Variables
Copy `.env.example` to `.env` and configure the following variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
-5
View File
@@ -183,7 +183,6 @@ Methods:
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_base_tools</a>() -> List[BaseTool]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools</a>() -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools_with_allowed_filter</a>(context: Optional[Dict[str, Any]] = None) -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">register</a>(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">unregister</a>(name: str) -> bool</code>
* <code title="class Tools">Tools.<a href="./src/praisonai-agents/praisonaiagents/tools/tools.py">internet_search</a>(*args, **kwargs)</code>
@@ -788,9 +787,6 @@ Methods:
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
@@ -858,7 +854,6 @@ Methods:
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.37" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.37" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.37" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -23,39 +23,12 @@ print(f"[2] Updated to v{managed.agent_version}")
result = agent.start("What is 123 + 456?", stream=True)
# ── 3. Custom tool ──
def _safe_calc(expr: str) -> str:
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expr):
return "error"
try:
tree = ast.parse(expr, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "error"
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return str(val)
except Exception:
return "error"
def handle_calculator(tool_name, tool_input):
expr = tool_input.get("expression", "0")
val = _safe_calc(str(expr))
try:
val = eval(expr, {"__builtins__": {}})
except Exception:
val = "error"
print(f" [Calculator: {expr} = {val}]")
return str(val)
-40
View File
@@ -1,40 +0,0 @@
# Scientific paper workflow — YAML equivalent of multi_agent_paper.py
# Run with:
# praisonai agents run --file examples/scientific_writing/agents.yaml
framework: praisonai
topic: "Climate change effects on coral reef biodiversity"
roles:
literature_reviewer:
role: "Literature Reviewer"
goal: "Survey recent academic work on {topic} and produce an APA review."
backstory: "Expert in academic literature surveying and citation hygiene."
llm: "gpt-4o-mini"
tasks:
review_literature:
description: "Produce a concise literature review with 5-8 APA citations."
expected_output: "APA-formatted literature review."
methodology_designer:
role: "Methodology Designer"
goal: "Design a reproducible research methodology for {topic}."
backstory: "Specialises in rigorous experimental design."
llm: "gpt-4o-mini"
tasks:
design_methodology:
description: "Write a reproducible methods section in LaTeX."
expected_output: "LaTeX methods section."
scientific_writer:
role: "Scientific Writer"
goal: "Assemble the final LaTeX paper on {topic}."
backstory: >-
Specialised in LaTeX-formatted academic papers, fine-tuned on
scientific literature (CAJAL-4B checkpoint).
llm: "huggingface/Agnuxo/CAJAL-4B-P2PCLAW"
tasks:
write_paper:
description: >-
Combine the literature review and methodology into a full
scientific paper on {topic}. Use LaTeX formatting.
expected_output: "Complete LaTeX paper."
@@ -1,73 +0,0 @@
"""Multi-agent scientific paper workflow (Researcher -> Methodologist -> Writer).
Demonstrates the agent-centric pattern from AGENTS.md: compose specialised
Agents with ``AgentTeam`` + sequential ``Task``s instead of creating custom
Agent subclasses. The writer uses the CAJAL-4B HuggingFace model; the
reviewer and methodologist use general-purpose LLMs.
Usage:
python examples/scientific_writing/multi_agent_paper.py
"""
from praisonaiagents import Agent, AgentTeam, Task
from scientific_writer import ( # noqa: E402 — example imports a sibling file
build_scientific_writer,
format_citation,
format_latex_section,
)
literature_reviewer = Agent(
name="Literature Reviewer",
instructions=(
"You are an expert at surveying academic literature. "
"Produce a concise literature review with 5-8 key citations in APA."
),
tools=[format_citation],
)
methodology_designer = Agent(
name="Methodology Designer",
instructions=(
"You design rigorous research methodologies. "
"Output a clear, reproducible methods section."
),
tools=[format_latex_section],
)
scientific_writer = build_scientific_writer()
def run(topic: str) -> str:
"""Run the 3-agent workflow for a given research ``topic``."""
t1 = Task(
name="review_literature",
description=f"Review literature on: {topic}",
agent=literature_reviewer,
expected_output="APA literature review.",
)
t2 = Task(
name="design_methodology",
description=f"Design research methodology for: {topic}",
agent=methodology_designer,
expected_output="LaTeX methods section.",
)
t3 = Task(
name="write_paper",
description=(
f"Combine the literature review and methodology into a full "
f"scientific paper on: {topic}. Use LaTeX formatting."
),
agent=scientific_writer,
expected_output="Complete LaTeX paper.",
)
team = AgentTeam(
agents=[literature_reviewer, methodology_designer, scientific_writer],
tasks=[t1, t2, t3],
)
return team.start()
if __name__ == "__main__":
result = run("Transformer architectures for protein folding prediction")
print(result)
@@ -1,85 +0,0 @@
"""Scientific Paper Writer — single-agent example using the CAJAL model.
CAJAL (``Agnuxo/CAJAL-4B-P2PCLAW``) is a 4B-parameter HuggingFace model
specialised for LaTeX-formatted academic writing. This example shows how
to use it with a regular ``Agent`` and ``@tool`` functions — no new Agent
subclass is needed. Run 3 ways: Python (this file), CLI, or YAML.
Requirements:
pip install "praisonaiagents[llm]" # for litellm / HuggingFace
export HUGGINGFACE_API_KEY=... # or run a local inference server
Usage:
python examples/scientific_writing/scientific_writer.py
"""
from praisonaiagents import Agent, tool
@tool
def format_latex_section(title: str, content: str) -> str:
"""Wrap prose content in a LaTeX ``\\section{}`` block.
Args:
title: Section heading (e.g. ``"Introduction"``).
content: The body text.
Returns:
LaTeX-formatted section string.
"""
return f"\\section{{{title}}}\n{content}\n"
@tool
def format_citation(authors: str, year: int, title: str, venue: str) -> str:
"""Render a single APA-style citation line.
Args:
authors: ``"Smith, J. & Jones, A."``
year: Publication year.
title: Paper title.
venue: Journal / conference name.
Returns:
One APA-formatted citation string.
"""
return f"{authors} ({year}). {title}. *{venue}*."
SCIENTIFIC_INSTRUCTIONS = """You are a specialised scientific paper writer.
Produce high-quality academic content with:
- Clear structure (Abstract, Introduction, Methodology, Results, Discussion,
Conclusion, References)
- Rigorous methodology and precise language
- Proper APA citations and LaTeX formatting
- Honest limitations and future work sections
Use ``format_latex_section`` to wrap each section in LaTeX, and
``format_citation`` to build the References list.
"""
def build_scientific_writer(
model: str = "huggingface/Agnuxo/CAJAL-4B-P2PCLAW",
) -> Agent:
"""Construct a scientific-writing Agent.
Override ``model`` for any HuggingFace / local / API model — the default
targets the CAJAL-4B checkpoint for LaTeX-style academic output.
"""
return Agent(
name="Scientific Writer",
instructions=SCIENTIFIC_INSTRUCTIONS,
llm=model,
tools=[format_latex_section, format_citation],
)
if __name__ == "__main__":
agent = build_scientific_writer()
paper = agent.start(
"Write a short scientific paper (Abstract + Introduction + Conclusion) "
"on 'Climate change effects on coral reef biodiversity'. "
"Use LaTeX formatting and include at least 3 APA citations."
)
print(paper)
+5 -26
View File
@@ -44,33 +44,12 @@ def main():
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid expression"
try:
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "Error: Invalid expression"
return str(
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
)
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
return "Error: Invalid expression"
except Exception as e:
return f"Error: {e}"
@@ -1,10 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (high-level BotOS API, no manual mgr).
This is not a pytest test file - it's an integration smoke test.
Uses the user-facing API only — ``Bot``, ``BotOS``, ``InMemoryIdentityResolver`` —
and exercises the same cross-platform-continuity scenario as
``smoke_w1_real.py`` but without touching ``BotSessionManager`` directly.
@@ -17,11 +13,10 @@ wire-up from ``BotOS(identity_resolver=...)`` → ``Bot._identity_resolver``
Run::
# From the repository root:
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_botos_real.py
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_botos_real.py
"""
# Prevent pytest from treating this as a test file
__test__ = False
from __future__ import annotations
import asyncio
import os
@@ -1,18 +1,13 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (bypasses pytest conftest gating).
This is not a pytest test file - it's an integration smoke test.
Runs::
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_real.py
cd src/praisonai
python ../../scripts/smoke_w1_real.py
"""
# Prevent pytest from treating this as a test file
__test__ = False
from __future__ import annotations
import asyncio
import os
@@ -1,10 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 deep-validation smoke test.
This is not a pytest test file - it's an integration smoke test.
Exercises every robustness dimension of W1 with REAL LLM calls:
T1. 3-platform unification (telegram → discord → slack, one user)
@@ -16,12 +12,11 @@ Exercises every robustness dimension of W1 with REAL LLM calls:
Run::
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_robust.py
cd /Users/praison/worktrees/hermes-parity
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_robust.py
"""
# Prevent pytest from treating this as a test file
__test__ = False
from __future__ import annotations
import asyncio
import os
@@ -21,7 +21,6 @@ from .chat_handler import ChatHandlerMixin
from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState
from .unified_execution_mixin import UnifiedExecutionMixin
from .sandbox_mixin import SandboxMixin
# Module-level logger for thread safety errors and debugging
logger = get_logger(__name__)
@@ -256,7 +255,7 @@ if TYPE_CHECKING:
# Import structured error from central errors module
from ..errors import BudgetExceededError
class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
_agent_counter_lock = threading.Lock()
@@ -556,7 +555,6 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
):
"""Initialize an Agent instance.
@@ -1948,9 +1946,6 @@ Your Goal: {self.goal}
# Telemetry - lazy initialized via property for performance
self.__telemetry = None
self.__telemetry_initialized = False
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
@property
def _telemetry(self):
@@ -753,11 +753,11 @@ Your Goal: {self.goal}"""
# Create LLMError with contextual metadata
error = LLMError(
str(e),
model_name=model_name,
agent_id=self.name,
str(e),
model_name=model_name,
agent_id=self.name,
is_retryable=is_retryable,
context={"session_id": session_id},
session_id=session_id
)
# Call error hook if available for error interception
@@ -902,7 +902,7 @@ Your Goal: {self.goal}"""
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
execute_tool_fn=getattr(self, 'execute_tool_async', None),
execute_tool_fn=getattr(self, 'execute_tool', None),
console=self.console if (self.verbose or stream) else None,
display_fn=self._display_generating if self.verbose else None,
stream_callback=stream_callback,
@@ -935,151 +935,6 @@ Your Goal: {self.goal}"""
logging.error(f"Unified async chat completion failed: {e}")
raise
async def _finalize_unified_achat_response(
self,
response,
original_prompt,
temperature,
tools,
output_json,
output_pydantic,
reasoning_steps,
stream,
messages,
chat_history_length,
start_time,
task_name=None,
task_description=None,
task_id=None,
):
"""Post-process unified achat completion (parity with sync _chat_impl)."""
if not response:
self._truncate_chat_history(chat_history_length)
return None
if hasattr(response, "choices") and response.choices:
msg = response.choices[0].message
content = getattr(msg, "content", None)
response_text = content.strip() if content else ""
if (
reasoning_steps
and getattr(msg, "reasoning_content", None)
):
response_text = msg.reasoning_content.strip()
else:
extracted = self._extract_llm_response_content(response)
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
if output_json or output_pydantic:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
self._truncate_chat_history(chat_history_length)
return None
if not self.self_reflect:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
self._truncate_chat_history(chat_history_length)
return None
# Self-reflection (legacy OpenAI path)
reflection_count = 0
while True:
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt},
]
try:
if self._openai_client is None:
self._append_to_chat_history({"role": "assistant", "content": response_text})
return await self._atrigger_after_agent_hook(
original_prompt, response_text, start_time,
)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput'],
)
reflection_output = reflection_response.choices[0].message.parsed
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
break
if reflection_count >= self.max_reflect - 1:
break
regenerate_messages = reflection_messages + [
{
"role": "assistant",
"content": (
f"Self Reflection: {reflection_output.reflection} "
f"Satisfactory?: {reflection_output.satisfactory}"
),
},
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
]
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature,
)
response_text = new_response.choices[0].message.content
reflection_count += 1
except Exception as e:
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
break
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
self._truncate_chat_history(chat_history_length)
return None
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
"""Helper method to execute callbacks and display interaction.
@@ -2187,22 +2042,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
# Continue to handle structured outputs and other processing
# Don't return immediately - fall through to existing logic
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -164,8 +164,7 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -205,8 +204,7 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -367,8 +365,6 @@ Provide clear, actionable feedback."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
review = response.choices[0].message.content
self._log("Review completed", "green")
return review
@@ -409,8 +405,6 @@ Include:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
explanation = response.choices[0].message.content
self._log("Explanation completed", "green")
return explanation
@@ -457,8 +451,6 @@ Only output the refactored code."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
refactored = response.choices[0].message.content
self._log("Refactoring completed", "green")
return refactored
@@ -503,8 +495,6 @@ Ensure the fix:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
fixed = response.choices[0].message.content
self._log("Fix completed", "green")
return fixed
@@ -1,231 +0,0 @@
"""
Sandbox execution mixin for Agent.
Provides safe code execution capabilities to agents via the sandbox framework.
"""
import logging
from typing import Optional, Dict, Any, Union
from ..sandbox import (
SandboxConfig,
SandboxManager,
SandboxResult,
check_code_safety,
format_warnings,
)
logger = logging.getLogger(__name__)
class SandboxMixin:
"""Mixin that adds sandbox execution capabilities to Agent.
Provides safe code execution in isolated environments with security pre-checks.
"""
def __init__(self, *args, **kwargs):
# Extract sandbox config from kwargs
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
# Convert boolean True to default config
if self.sandbox_config is True:
self.sandbox_config = SandboxConfig.subprocess()
super().__init__(*args, **kwargs)
self._sandbox_manager: Optional[SandboxManager] = None
@property
def has_sandbox(self) -> bool:
"""Check if sandbox is configured for this agent."""
return self.sandbox_config is not None
def get_sandbox_manager(self) -> Optional[SandboxManager]:
"""Get or create sandbox manager."""
if not self.has_sandbox:
return None
if self._sandbox_manager is None:
self._sandbox_manager = SandboxManager(self.sandbox_config)
return self._sandbox_manager
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Execute code safely in configured sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
SandboxResult with execution details
Raises:
RuntimeError: If no sandbox is configured
"""
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
# Security pre-check if enabled
if check_security:
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
# Store warnings in result metadata
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
manager = self.get_sandbox_manager()
return await manager.run_code(code, language=language, **kwargs)
def execute_code_sync(
self,
code: str,
language: str = "python",
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Synchronous wrapper for execute_code.
Args:
code: Code to execute
language: Programming language
check_security: Whether to run security pre-checks
**kwargs: Additional arguments
Returns:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Run a shell command in the sandbox.
Args:
command: Shell command to run
check_security: Whether to run security pre-checks
**kwargs: Additional arguments
Returns:
SandboxResult with execution details
"""
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
# Convert command to string for security checking
if isinstance(command, list):
command_str = " ".join(command)
else:
command_str = command
# Security pre-check
if check_security:
warnings = check_code_safety(command_str, "bash")
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with self.get_sandbox_manager() as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
"""Get sandbox status information.
Returns:
Dictionary with sandbox status details
"""
if not self.has_sandbox:
return {"configured": False}
status = {
"configured": True,
"config": self.sandbox_config.to_dict() if self.sandbox_config else None,
}
manager = self.get_sandbox_manager()
if manager:
# Get available sandbox types
available_types = manager.get_available_types()
status["available_types"] = available_types
status["current_type"] = self.sandbox_config.sandbox_type
return status
async def sandbox_cleanup(self):
"""Clean up sandbox resources."""
if self._sandbox_manager:
# The context manager handles cleanup, but we can force it here
try:
await self._sandbox_manager.__aexit__(None, None, None)
except Exception as e:
logger.warning(f"Error during sandbox cleanup: {e}")
finally:
self._sandbox_manager = None
def _get_code_execution_tools(self):
"""Get sandbox-based code execution tools for this agent.
Returns tools that use the configured sandbox for safe execution.
Returns:
List of tool functions
"""
if not self.has_sandbox:
return []
from ..tools import tool
@tool
def execute_python_code(code: str) -> str:
"""Execute Python code safely in sandbox.
Args:
code: Python code to execute
Returns:
Execution output
"""
result = self.execute_code_sync(code, language="python")
if result.success:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
@tool
def execute_shell_command(command: str) -> str:
"""Execute shell command safely in sandbox.
Args:
command: Shell command to execute
Returns:
Command output
"""
from ..approval.utils import run_coroutine_safely
result = run_coroutine_safely(self.run_shell_command(command))
if result.success:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
return [execute_python_code, execute_shell_command]
@@ -126,24 +126,7 @@ class _AgentServerRegistry:
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
# Check for configurable timeout via environment variable
try:
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
except ValueError:
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
timeout = 5.0
became_ready = ready_event.wait(timeout=timeout)
if not became_ready:
logger.warning(
"Agent server on port %s did not become ready within %.1fs. "
"Proceeding, but some features may not work correctly. "
"Check server logs for startup errors.",
port,
timeout,
)
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
return True
@@ -1,257 +0,0 @@
"""
Tool whitelist filter module with diagnostics.
This module provides a canonical implementation of the ALLOWED_TOOLS filter
that prevents tool name collisions in multi-environment agent systems by
whitelisting only specified tools.
The filter is designed to solve the "tool shadowing" problem where multiple
modules register tools with overlapping or ambiguous names, causing agents
to invoke the wrong implementation.
Environment Variables:
ALLOWED_TOOLS: Primary variable (comma-separated tool names)
HERMES_ONLY_TOOLS: Backward compatibility alias for ALLOWED_TOOLS
Usage:
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
# Create filter from environment variable
filter = AllowedToolsFilter()
# Filter tools
available_tools = {"search", "send_message", "extract_pdf"}
filtered_tools = filter.filter_tools(available_tools)
# Get diagnostics
filter.log_diagnostics()
"""
import os
import logging
from typing import Any, Dict, List, Optional, Set, Union
logger = logging.getLogger(__name__)
class AllowedToolsFilter:
"""
Canonical tool whitelist filter with diagnostics and backward compatibility.
This filter implements the semantics for tool filtering:
- ALLOWED_TOOLS unset (or HERMES_ONLY_TOOLS): all tools visible (with warning about collisions)
- ALLOWED_TOOLS empty string: error - must specify tools or unset
- ALLOWED_TOOLS with values: only whitelisted tools visible
- Unknown tools in list: warn and strip in dev, strict fail in CI
Backward Compatibility:
- Supports both ALLOWED_TOOLS and HERMES_ONLY_TOOLS
- ALLOWED_TOOLS takes precedence if both are set
"""
def __init__(self, env_var_name: str = "ALLOWED_TOOLS"):
"""
Initialize the filter with backward compatibility support.
Args:
env_var_name: Primary environment variable name (default: "ALLOWED_TOOLS")
"""
# Support both new (ALLOWED_TOOLS) and legacy (HERMES_ONLY_TOOLS) naming
self.primary_var = env_var_name
self.legacy_var = "HERMES_ONLY_TOOLS"
# ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS for backward compatibility
self.env_value = os.environ.get(self.primary_var) or os.environ.get(self.legacy_var)
self.env_var_name = self.primary_var if os.environ.get(self.primary_var) else self.legacy_var
self.is_ci = os.environ.get("CI", "").lower() in ("true", "1", "yes")
self._whitelist: Optional[Set[str]] = self._parse_whitelist()
self._diagnostics: Dict[str, Any] = {}
def _parse_whitelist(self) -> Optional[Set[str]]:
"""
Parse the environment variable value into a whitelist set.
Returns:
None if unset, Set of tool names if set, raises ValueError for empty string
"""
if self.env_value is None:
return None
# Empty string is an error - must be explicit
if self.env_value.strip() == "":
raise ValueError(
f"{self.env_var_name} cannot be empty. Either unset it for all tools "
"or provide a comma-separated list of tool names."
)
# Parse comma-separated values, strip whitespace
tools = {name.strip() for name in self.env_value.split(",") if name.strip()}
if not tools:
raise ValueError(
f"{self.env_var_name} contains no valid tool names. "
"Provide comma-separated tool names or unset the variable."
)
return tools
def filter_tools(self, available_tools: Union[Set[str], List[str], Dict[str, Any]]) -> Set[str]:
"""
Filter tools based on ALLOWED_TOOLS whitelist.
Args:
available_tools: Available tools as set, list, or dict with tool names as keys
Returns:
Filtered set of tool names
Raises:
ValueError: If unknown tools in CI mode or configuration errors
"""
# Convert input to set of tool names
if isinstance(available_tools, dict):
tool_names = set(available_tools.keys())
else:
tool_names = set(available_tools)
# Store for diagnostics
self._diagnostics["registered_before_filter"] = sorted(tool_names)
# If no whitelist, return all tools (with warning)
if self._whitelist is None:
logger.warning(
"Tool whitelist is unset. All %d tools are visible. "
"Consider using ALLOWED_TOOLS to prevent tool name collisions.",
len(tool_names)
)
self._diagnostics["registered_after_filter"] = sorted(tool_names)
self._diagnostics["dropped_tools"] = []
self._diagnostics["unknown_tools"] = []
return tool_names
# Find intersection and unknown tools
available_whitelisted = tool_names.intersection(self._whitelist)
unknown_tools = self._whitelist.difference(tool_names)
dropped_tools = tool_names.difference(self._whitelist)
# Store for diagnostics
self._diagnostics["registered_after_filter"] = sorted(available_whitelisted)
self._diagnostics["dropped_tools"] = sorted(dropped_tools)
self._diagnostics["unknown_tools"] = sorted(unknown_tools)
# Handle unknown tools based on environment
if unknown_tools:
unknown_list = sorted(unknown_tools)
if self.is_ci:
raise ValueError(
f"Unknown tools in {self.env_var_name}: {unknown_list}. "
"All specified tools must be available in CI mode."
)
else:
logger.warning(
"Unknown tools in %s will be ignored: %s",
self.env_var_name,
unknown_list
)
if dropped_tools:
logger.info(
"Dropped %d tools not in %s whitelist: %s",
len(dropped_tools),
self.env_var_name,
sorted(dropped_tools)
)
logger.info(
"%s filtered tools: %d available, %d after filter",
self.env_var_name,
len(tool_names),
len(available_whitelisted)
)
return available_whitelisted
def log_diagnostics(self) -> None:
"""Log startup diagnostics section as specified in the issue."""
if not self._diagnostics:
logger.warning("No diagnostics available. Call filter_tools() first.")
return
logger.info("=" * 50)
logger.info("ALLOWED_TOOLS FILTER DIAGNOSTICS")
logger.info("=" * 50)
logger.info("%s=%s", self.env_var_name, self.env_value or "<unset>")
logger.info("RegisteredBeforeFilter=%s", self._diagnostics.get("registered_before_filter", []))
logger.info("RegisteredAfterFilter=%s", self._diagnostics.get("registered_after_filter", []))
logger.info("DroppedTools=%s", self._diagnostics.get("dropped_tools", []))
logger.info("UnknownTools=%s", self._diagnostics.get("unknown_tools", []))
logger.info("IsCI=%s", self.is_ci)
logger.info("=" * 50)
def get_diagnostics(self) -> Dict[str, Any]:
"""
Get diagnostics data as a dictionary.
Returns:
Dictionary with diagnostics information
"""
return {
"env_var_name": self.env_var_name,
"env_value": self.env_value,
"is_ci": self.is_ci,
"whitelist": list(self._whitelist) if self._whitelist else None,
**self._diagnostics
}
def is_enabled(self) -> bool:
"""
Check if filtering is enabled.
Returns:
True if tool whitelist is set and filtering is active
"""
return self._whitelist is not None
def get_whitelist(self) -> Optional[Set[str]]:
"""
Get the current whitelist.
Returns:
Set of whitelisted tool names, or None if not set
"""
return self._whitelist.copy() if self._whitelist else None
def filter_tools_with_allowed_tools(
available_tools: Union[Set[str], List[str], Dict[str, Any]],
env_var_name: str = "ALLOWED_TOOLS",
log_diagnostics: bool = True
) -> Set[str]:
"""
Convenience function to filter tools with ALLOWED_TOOLS.
Args:
available_tools: Available tools to filter
env_var_name: Environment variable name (default: "ALLOWED_TOOLS")
log_diagnostics: Whether to log diagnostics (default: True)
Returns:
Filtered set of tool names
"""
filter_instance = AllowedToolsFilter(env_var_name)
filtered = filter_instance.filter_tools(available_tools)
if log_diagnostics:
filter_instance.log_diagnostics()
return filtered
# Backward compatibility aliases
filter_tools_with_hermes = filter_tools_with_allowed_tools
hermes_filter = filter_tools_with_allowed_tools
apply_hermes_filter = filter_tools_with_allowed_tools
# Legacy class alias for backward compatibility
HermesToolFilter = AllowedToolsFilter
@@ -42,7 +42,6 @@ class MemoryBackend(str, Enum):
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
@@ -7,7 +7,7 @@ Inspired by Agno's SessionContextStore pattern.
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
from datetime import datetime
import json
import logging
from praisonaiagents._logging import get_logger
@@ -97,8 +97,8 @@ class SessionContextTracker:
self._state = SessionState(
session_id=self.session_id,
created_at=datetime.now(timezone.utc).isoformat(),
updated_at=datetime.now(timezone.utc).isoformat(),
created_at=datetime.utcnow().isoformat() + "Z",
updated_at=datetime.utcnow().isoformat() + "Z",
)
def _generate_session_id(self) -> str:
@@ -163,7 +163,7 @@ class SessionContextTracker:
def _mark_updated(self) -> None:
"""Update the updated_at timestamp."""
self._state.updated_at = datetime.now(timezone.utc).isoformat()
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
self._state.turn_count += 1
def to_context_string(self) -> str:
@@ -257,7 +257,7 @@ Current messages take precedence if there's any conflict with this summary.
self._state = SessionState(
session_id=self.session_id,
created_at=self._state.created_at,
updated_at=datetime.now(timezone.utc).isoformat(),
updated_at=datetime.utcnow().isoformat() + "Z",
)
def to_dict(self) -> Dict[str, Any]:
@@ -12,7 +12,7 @@ import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = get_logger(__name__)
@@ -39,7 +39,7 @@ class GradeResult:
input_text: str = ""
output: str = ""
expected_output: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -55,7 +55,7 @@ class GradeResult:
input_text=data.get("input_text", ""),
output=data.get("output", ""),
expected_output=data.get("expected_output"),
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
timestamp=data.get("timestamp", datetime.utcnow().isoformat()),
)
class BaseLLMGrader:
@@ -907,8 +907,6 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1169,8 +1167,6 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1642,10 +1638,8 @@ class OpenAIClient:
)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -1841,12 +1835,10 @@ class OpenAIClient:
if not final_response:
return None
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -2045,8 +2037,6 @@ class OpenAIClient:
return
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
@@ -7,7 +7,7 @@ import threading
from typing import Any, Dict, List, Optional, Union, Literal
import logging
from praisonaiagents._logging import get_logger
from datetime import datetime, timezone
from datetime import datetime
# Decomposed memory functionality - imported as mixins for backward compatibility
from .storage import StorageMixin
@@ -692,7 +692,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.now(timezone.utc),
"created_at": datetime.utcnow(),
"memory_type": "short_term"
}
self.mongo_short_term.insert_one(doc)
@@ -918,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.now(timezone.utc),
"created_at": datetime.utcnow(),
"memory_type": "long_term"
}
@@ -1441,14 +1441,14 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
self.mem0_client.add(text, user_id=user_id, metadata=meta)
elif self.use_mongodb and hasattr(self, "mongo_users"):
try:
from datetime import datetime, timezone
from datetime import datetime
ident = str(time.time_ns())
doc = {
"_id": ident,
"user_id": user_id,
"content": text,
"metadata": meta,
"created_at": datetime.now(timezone.utc)
"created_at": datetime.utcnow()
}
self.mongo_users.insert_one(doc)
self._log_verbose(f"Successfully stored user memory for {user_id}")
@@ -15,8 +15,6 @@ from .protocols import (
ResourceLimits,
)
from .config import SandboxConfig, SecurityPolicy
from .manager import SandboxManager
from .security import SecurityWarning, check_code_safety, format_warnings, get_security_summary
__all__ = [
"SandboxProtocol",
@@ -24,10 +22,5 @@ __all__ = [
"SandboxStatus",
"ResourceLimits",
"SandboxConfig",
"SecurityPolicy",
"SandboxManager",
"SecurityWarning",
"check_code_safety",
"format_warnings",
"get_security_summary",
"SecurityPolicy",
]
@@ -1,302 +0,0 @@
"""
SandboxManager for PraisonAI Agents.
Factory and context manager for sandbox backends.
Core SDK component that routes to appropriate sandbox implementations
in the praisonai wrapper package.
"""
from __future__ import annotations
import logging
from typing import Dict, Any, Optional, TYPE_CHECKING
from .config import SandboxConfig
from .protocols import SandboxProtocol, SandboxResult
if TYPE_CHECKING:
from typing import AsyncContextManager
logger = logging.getLogger(__name__)
class SandboxManager:
"""Factory and context manager for all sandbox backends.
Routes to appropriate sandbox implementations based on configuration.
Lightweight manager in core SDK - heavy implementations in wrapper.
Example:
from praisonaiagents.sandbox import SandboxManager, SandboxConfig
# Simple usage
config = SandboxConfig.docker("python:3.11-slim")
manager = SandboxManager(config)
result = await manager.run_code("print('Hello, World!')")
# Context manager usage
async with SandboxManager(config) as sandbox:
result = await sandbox.execute("print('Hello, World!')")
"""
def __init__(self, config: Optional[SandboxConfig] = None):
"""Initialize the sandbox manager.
Args:
config: Sandbox configuration. Defaults to subprocess sandbox.
"""
self.config = config or SandboxConfig.subprocess()
self._sandbox: Optional[SandboxProtocol] = None
async def __aenter__(self) -> SandboxProtocol:
"""Async context manager entry."""
self._sandbox = await self._create_sandbox()
return self._sandbox
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit with cleanup."""
if self._sandbox:
try:
await self._sandbox.stop()
await self._sandbox.cleanup()
except Exception as e:
logger.warning(f"Error during sandbox cleanup: {e}")
finally:
self._sandbox = None
async def run_code(
self,
code: str,
language: str = "python",
**kwargs
) -> SandboxResult:
"""Convenience method: create sandbox, run code, cleanup.
Args:
code: Code to execute
language: Programming language
**kwargs: Additional arguments passed to execute()
Returns:
SandboxResult with execution details
"""
async with self as sandbox:
return await sandbox.execute(code, language=language, **kwargs)
async def _create_sandbox(self) -> SandboxProtocol:
"""Create appropriate sandbox backend based on config.
Routes to implementations in praisonai wrapper package.
Returns:
Configured sandbox instance
Raises:
ValueError: For unknown sandbox types
ImportError: If required dependencies not available
"""
sandbox_type = self.config.sandbox_type.lower()
if sandbox_type == "docker":
return await self._create_docker_sandbox()
elif sandbox_type in ("subprocess", "local"):
return await self._create_subprocess_sandbox()
elif sandbox_type == "e2b":
return await self._create_e2b_sandbox()
elif sandbox_type == "sandlock":
return await self._create_sandlock_sandbox()
elif sandbox_type == "ssh":
return await self._create_ssh_sandbox()
elif sandbox_type == "modal":
return await self._create_modal_sandbox()
elif sandbox_type == "daytona":
return await self._create_daytona_sandbox()
else:
raise ValueError(
f"Unknown sandbox type: {sandbox_type!r}. "
f"Supported: 'docker', 'subprocess', 'e2b', 'sandlock', 'ssh', 'modal', 'daytona'"
)
async def _create_docker_sandbox(self) -> SandboxProtocol:
"""Create Docker sandbox."""
try:
# Lazy import from wrapper package
from praisonai.sandbox import DockerSandbox
except ImportError as e:
raise ImportError(
"Docker sandbox not available. Install with: "
"pip install praisonaiagents[sandbox-docker]"
) from e
sandbox = DockerSandbox(
image=self.config.image,
config=self.config,
)
if not sandbox.is_available:
raise RuntimeError(
"Docker is not available. Please install Docker and ensure it's running."
)
await sandbox.start()
return sandbox
async def _create_subprocess_sandbox(self) -> SandboxProtocol:
"""Create subprocess sandbox."""
try:
from praisonai.sandbox import SubprocessSandbox
except ImportError as e:
raise ImportError(
"Subprocess sandbox not available. This should not happen - "
"please check your installation."
) from e
sandbox = SubprocessSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_e2b_sandbox(self) -> SandboxProtocol:
"""Create E2B sandbox."""
try:
from praisonai.sandbox import E2BSandbox
except ImportError as e:
raise ImportError(
"E2B sandbox not available. Install with: "
"pip install praisonaiagents[sandbox] e2b-code-interpreter"
) from e
sandbox = E2BSandbox(config=self.config)
if not sandbox.is_available:
raise RuntimeError(
"E2B is not available. Please set E2B_API_KEY environment variable."
)
await sandbox.start()
return sandbox
async def _create_sandlock_sandbox(self) -> SandboxProtocol:
"""Create Sandlock sandbox."""
try:
from praisonai.sandbox import SandlockSandbox
except ImportError as e:
raise ImportError(
"Sandlock sandbox not available. Install sandlock."
) from e
sandbox = SandlockSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_ssh_sandbox(self) -> SandboxProtocol:
"""Create SSH sandbox."""
try:
from praisonai.sandbox import SSHSandbox
except ImportError as e:
raise ImportError(
"SSH sandbox not available. Install with: "
"pip install paramiko"
) from e
sandbox = SSHSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_modal_sandbox(self) -> SandboxProtocol:
"""Create Modal sandbox."""
try:
from praisonai.sandbox import ModalSandbox
except ImportError as e:
raise ImportError(
"Modal sandbox not available. Install with: "
"pip install modal"
) from e
sandbox = ModalSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_daytona_sandbox(self) -> SandboxProtocol:
"""Create Daytona sandbox."""
try:
from praisonai.sandbox import DaytonaSandbox
except ImportError as e:
raise ImportError(
"Daytona sandbox not available. Install daytona client."
) from e
sandbox = DaytonaSandbox(config=self.config)
await sandbox.start()
return sandbox
def get_available_types(self) -> Dict[str, Dict[str, Any]]:
"""Get available sandbox types and their status.
Returns:
Dictionary mapping sandbox types to availability info
"""
types = {}
# Check Docker
try:
from praisonai.sandbox import DockerSandbox
docker = DockerSandbox()
types["docker"] = {
"available": docker.is_available,
"description": "Isolated Docker containers",
"requires": ["docker"],
}
except ImportError:
types["docker"] = {
"available": False,
"description": "Isolated Docker containers",
"requires": ["docker", "praisonaiagents[sandbox-docker]"],
}
# Check subprocess (always available)
types["subprocess"] = {
"available": True,
"description": "Local subprocess (limited isolation)",
"requires": [],
}
# Check E2B
try:
from praisonai.sandbox import E2BSandbox
e2b = E2BSandbox()
types["e2b"] = {
"available": e2b.is_available,
"description": "E2B cloud sandboxes",
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
}
except ImportError:
types["e2b"] = {
"available": False,
"description": "E2B cloud sandboxes",
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
}
# Add other types with try/except blocks
for sandbox_type, module_name, description, requirements in [
("sandlock", "SandlockSandbox", "OS-native sandboxing", ["sandlock"]),
("ssh", "SSHSandbox", "Remote SSH execution", ["paramiko"]),
("modal", "ModalSandbox", "Modal cloud compute", ["modal"]),
("daytona", "DaytonaSandbox", "Daytona workspaces", ["daytona"]),
]:
try:
from praisonai import sandbox
sandbox_class = getattr(sandbox, module_name)
# Check if class exists without instantiating (some need required args)
types[sandbox_type] = {
"available": False, # Can't easily check without required args
"description": description,
"requires": requirements,
}
except (ImportError, AttributeError):
types[sandbox_type] = {
"available": False,
"description": description,
"requires": requirements,
}
return types
@@ -1,345 +0,0 @@
"""
Security pre-checks for sandbox code execution.
Provides pre-execution analysis of code for potentially dangerous patterns.
Note: These are warnings only - the sandbox provides the real isolation.
"""
import re
import ast
import logging
from typing import List, Optional, Dict, Any
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class SecurityWarning:
"""A security warning from code analysis."""
pattern: str
message: str
severity: str # "low", "medium", "high", "critical"
line_number: Optional[int] = None
context: Optional[str] = None
# Dangerous patterns to detect in code
DANGEROUS_PATTERNS = [
# System/OS access
(r"import\s+os\s*;?\s*os\.system", "Direct system command execution via os.system", "high"),
(r"subprocess\.call.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"subprocess\.run.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"subprocess\.Popen.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"__import__\(['\"]os['\"]\)", "Dynamic import of os module", "medium"),
(r"eval\s*\(", "Dynamic code execution with eval()", "critical"),
(r"exec\s*\(", "Dynamic code execution with exec()", "critical"),
(r"compile\s*\(.*exec", "Code compilation for execution", "high"),
# File system access
(r"open\s*\(.*['\"]\/etc", "Access to system configuration files", "medium"),
(r"open\s*\(.*['\"]\/root", "Access to root directory", "high"),
(r"open\s*\(.*['\"]\/home", "Access to user directories", "medium"),
(r"shutil\.rmtree", "Recursive directory deletion", "high"),
(r"os\.remove\s*\(", "File deletion", "medium"),
(r"os\.unlink\s*\(", "File deletion", "medium"),
(r"pathlib\.Path.*unlink", "File deletion via pathlib", "medium"),
# Network access
(r"socket\.socket", "Direct socket creation", "medium"),
(r"urllib\.request", "HTTP requests via urllib", "low"),
(r"requests\.", "HTTP requests via requests library", "low"),
(r"http\.client", "HTTP client usage", "low"),
# Process manipulation
(r"os\.fork\s*\(", "Process forking", "high"),
(r"multiprocessing\.", "Multiprocessing usage", "medium"),
(r"threading\.Thread", "Thread creation", "low"),
# Potentially dangerous builtins
(r"getattr\s*\(.*__", "Dynamic attribute access to dunder methods", "medium"),
(r"setattr\s*\(.*__", "Dynamic attribute setting of dunder methods", "medium"),
(r"hasattr\s*\(.*__", "Dynamic attribute checking of dunder methods", "low"),
(r"globals\s*\(\)", "Access to global namespace", "medium"),
(r"locals\s*\(\)", "Access to local namespace", "low"),
(r"vars\s*\(", "Variable inspection", "low"),
(r"dir\s*\(", "Object inspection", "low"),
# Code modification
(r"sys\.modules", "Module manipulation", "high"),
(r"importlib\.", "Dynamic import manipulation", "medium"),
(r"__builtins__", "Access to builtin functions", "high"),
# Memory/performance
(r"while\s+True:", "Infinite loop (potential DoS)", "medium"),
(r"for.*in.*range\s*\(\s*[0-9]{6,}", "Large range iteration", "low"),
(r"list\s*\(\s*range\s*\(\s*[0-9]{6,}", "Large list creation", "low"),
(r"\*\s*[0-9]{6,}", "Large data multiplication", "low"),
]
def check_code_safety(code: str, language: str = "python") -> List[SecurityWarning]:
"""Analyze code for potentially dangerous patterns.
Args:
code: Code to analyze
language: Programming language (currently only 'python' supported)
Returns:
List of security warnings found
Note:
These are warnings only. The sandbox provides real isolation.
This is a best-effort static analysis for user awareness.
"""
warnings = []
if language == "python":
warnings.extend(_check_python_patterns(code))
warnings.extend(_check_python_ast(code))
elif language == "bash":
warnings.extend(_check_bash_patterns(code))
else:
# Fall back to basic pattern matching
warnings.extend(_check_generic_patterns(code))
return warnings
def _check_python_patterns(code: str) -> List[SecurityWarning]:
"""Check Python code using regex patterns."""
warnings = []
lines = code.split('\n')
for i, line in enumerate(lines, 1):
for pattern, message, severity in DANGEROUS_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
def _check_python_ast(code: str) -> List[SecurityWarning]:
"""Check Python code using AST analysis."""
warnings = []
try:
tree = ast.parse(code)
visitor = DangerousASTVisitor()
visitor.visit(tree)
warnings.extend(visitor.warnings)
except SyntaxError:
# Invalid Python syntax - let the sandbox handle it
pass
except Exception as e:
logger.debug(f"AST analysis failed: {e}")
return warnings
def _check_bash_patterns(code: str) -> List[SecurityWarning]:
"""Check bash/shell code for dangerous patterns."""
warnings = []
lines = code.split('\n')
bash_patterns = [
(r"rm\s+-rf\s+\/", "Recursive deletion of root directory", "critical"),
(r"rm\s+-rf\s+\*", "Recursive deletion with wildcard", "high"),
(r">\s*\/dev\/sd[a-z]", "Direct disk device access", "critical"),
(r"dd\s+if=.*of=", "Low-level disk operations", "high"),
(r"mkfs\.", "Filesystem creation", "critical"),
(r"fdisk", "Disk partitioning", "critical"),
(r"shutdown", "System shutdown", "high"),
(r"reboot", "System reboot", "high"),
(r"halt", "System halt", "high"),
(r"init\s+[0-6]", "System runlevel change", "high"),
(r"chmod\s+777", "Overly permissive file permissions", "medium"),
(r"curl.*\|\s*bash", "Piped execution from network", "high"),
(r"wget.*\|\s*bash", "Piped execution from network", "high"),
(r"\$\(.*\)", "Command substitution", "medium"),
(r"`.*`", "Command substitution (backticks)", "medium"),
]
for i, line in enumerate(lines, 1):
for pattern, message, severity in bash_patterns:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
def _check_generic_patterns(code: str) -> List[SecurityWarning]:
"""Check code using generic patterns."""
warnings = []
lines = code.split('\n')
generic_patterns = [
(r"eval\s*\(", "Dynamic code execution", "critical"),
(r"exec\s*\(", "Dynamic code execution", "critical"),
(r"system\s*\(", "System command execution", "high"),
(r"shell\s*\(", "Shell command execution", "high"),
]
for i, line in enumerate(lines, 1):
for pattern, message, severity in generic_patterns:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
class DangerousASTVisitor(ast.NodeVisitor):
"""AST visitor to detect dangerous Python constructs."""
def __init__(self):
self.warnings = []
def visit_Call(self, node):
"""Visit function calls."""
# Check for dangerous function calls
if isinstance(node.func, ast.Name):
func_name = node.func.id
if func_name in ("eval", "exec", "compile"):
self.warnings.append(SecurityWarning(
pattern=f"{func_name}()",
message=f"Dynamic code execution with {func_name}()",
severity="critical",
line_number=node.lineno,
))
# Check for attribute calls like os.system
elif isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
module = node.func.value.id
func = node.func.attr
if module == "os" and func == "system":
self.warnings.append(SecurityWarning(
pattern="os.system()",
message="Direct system command execution",
severity="high",
line_number=node.lineno,
))
elif module == "subprocess" and func in ("call", "run", "Popen"):
# Check if shell=True is used
for keyword in node.keywords:
if keyword.arg == "shell" and isinstance(keyword.value, ast.Constant):
if keyword.value.value is True:
self.warnings.append(SecurityWarning(
pattern=f"subprocess.{func}(..., shell=True)",
message="Shell injection risk with subprocess",
severity="high",
line_number=node.lineno,
))
self.generic_visit(node)
def visit_Import(self, node):
"""Visit import statements."""
for alias in node.names:
if alias.name in ("os", "subprocess", "sys"):
self.warnings.append(SecurityWarning(
pattern=f"import {alias.name}",
message=f"Import of potentially dangerous module: {alias.name}",
severity="low",
line_number=node.lineno,
))
self.generic_visit(node)
def visit_ImportFrom(self, node):
"""Visit from ... import statements."""
if node.module in ("os", "subprocess", "sys"):
for alias in node.names:
if alias.name in ("system", "call", "run", "Popen", "modules"):
self.warnings.append(SecurityWarning(
pattern=f"from {node.module} import {alias.name}",
message=f"Import of dangerous function: {alias.name}",
severity="medium",
line_number=node.lineno,
))
self.generic_visit(node)
def format_warnings(warnings: List[SecurityWarning]) -> str:
"""Format security warnings for display.
Args:
warnings: List of security warnings
Returns:
Formatted string of warnings
"""
if not warnings:
return "No security issues detected."
# Group by severity
by_severity = {"critical": [], "high": [], "medium": [], "low": []}
for warning in warnings:
by_severity[warning.severity].append(warning)
lines = []
lines.append(f"Security analysis found {len(warnings)} potential issue(s):")
lines.append("")
for severity in ["critical", "high", "medium", "low"]:
if by_severity[severity]:
lines.append(f"{severity.upper()} RISK:")
for warning in by_severity[severity]:
line_info = f" (line {warning.line_number})" if warning.line_number else ""
context_info = f"\n Context: {warning.context}" if warning.context else ""
lines.append(f" - {warning.message}{line_info}{context_info}")
lines.append("")
lines.append("Note: These are warnings only. The sandbox provides real isolation.")
return "\n".join(lines)
def get_security_summary(warnings: List[SecurityWarning]) -> Dict[str, Any]:
"""Get security analysis summary.
Args:
warnings: List of security warnings
Returns:
Dictionary with summary statistics
"""
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for warning in warnings:
by_severity[warning.severity] += 1
max_severity = "low"
if by_severity["critical"] > 0:
max_severity = "critical"
elif by_severity["high"] > 0:
max_severity = "high"
elif by_severity["medium"] > 0:
max_severity = "medium"
return {
"total_warnings": len(warnings),
"by_severity": by_severity,
"max_severity": max_severity,
"is_safe": len(warnings) == 0,
"has_critical": by_severity["critical"] > 0,
}
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict
logger = logging.getLogger(__name__)
from .models import SkillMetadata
from .discovery import discover_skills, get_default_skill_dirs
from .discovery import discover_skills
from .loader import SkillLoader, LoadedSkill
from .prompt import generate_skills_xml
from .substitution import render_skill_body
@@ -271,7 +271,8 @@ class SkillManager:
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
# Create skill directory
skill_dirs = get_default_skill_dirs()
from .discovery import get_default_skill_directories
skill_dirs = get_default_skill_directories()
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
import os
@@ -346,15 +346,13 @@ class _LazyStorageModule:
_BACKENDS = {
"RedisBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"PostgreSQLBackend": "praisonai.storage",
"DynamoDBBackend": "praisonai.storage",
"ValkeyBackend": "praisonai.storage",
"ValkeySearchBackend": "praisonai.storage",
# Legacy names for compatibility
"RedisStorageAdapter": "praisonai.storage",
"MongoDBStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"DynamoDBStorageAdapter": "praisonai.storage",
}
@@ -392,7 +390,7 @@ def get_backend(
Factory function to get a storage backend.
Args:
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
**kwargs: Backend-specific arguments
Returns:
@@ -433,13 +431,9 @@ def get_backend(
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "DynamoDBBackend")
return backend_class(**kwargs)
elif backend_type == "valkey":
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "ValkeyBackend")
return backend_class(**kwargs)
else:
raise ValueError(f"Unknown backend type: {backend_type}. "
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb")
def __getattr__(name: str):
"""
@@ -448,8 +442,7 @@ def __getattr__(name: str):
This allows `from praisonaiagents.storage.backends import RedisBackend` to work
while maintaining lazy loading.
"""
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
"ValkeyBackend", "ValkeySearchBackend"}:
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend"}:
return getattr(_heavy_backends, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -10,7 +10,7 @@ _tools_lazy_cache = {}
# Export core tool items for organized imports (lightweight)
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .decorator import tool, FunctionTool
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, list_tools_with_allowed_filter, list_tools_with_hermes_filter, ToolRegistry
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, ToolRegistry
from .tools import Tools
# Export Injected type directly for easy access
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
# Python Tools
'execute_code': ('.python_tools', None),
'analyze_code': ('.python_tools', None),
'format_code': ('.python_tools', None),
'lint_code': ('.python_tools', None),
'disassemble_code': ('.python_tools', None),
'python_tools': ('.python_tools', None),
'execute_code': ('.python_tools', 'PythonTools'),
'analyze_code': ('.python_tools', 'PythonTools'),
'format_code': ('.python_tools', 'PythonTools'),
'lint_code': ('.python_tools', 'PythonTools'),
'disassemble_code': ('.python_tools', 'PythonTools'),
'python_tools': ('.python_tools', 'PythonTools'),
# Chain of Thought Training Tools
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
if name in [
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
@@ -363,7 +363,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
'list_tools', 'list_available_tools', 'ToolRegistry',
'Tools',
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
@@ -1,52 +0,0 @@
"""
A2UI tools — optional declarative UI via Google's a2ui-agent-sdk.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
agent = Agent(name="assistant", tools=[send_a2ui_messages])
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Union
from praisonaiagents.tools.decorator import tool
@tool
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
"""
Send validated A2UI JSON messages to a connected A2UI renderer client.
Pass a list of A2UI message dicts (createSurface, updateComponents, etc.)
or a JSON string. Requires: pip install praisonaiagents[a2ui]
Args:
messages: A2UI message list or JSON string from the model.
Returns:
Dict with mime_type and serialised messages for A2A/AG-UI clients.
"""
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
part = create_a2ui_part({"messages": messages})
data = getattr(getattr(part, "root", part), "data", messages)
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
return {
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
"messages": messages,
"a2ui_part": data,
}
@@ -268,11 +268,6 @@ class MentionsParser:
def _process_url_mention(self, url: str) -> Optional[str]:
"""Process @url:https://... mention."""
try:
from praisonaiagents.tools.spider_tools import SpiderTools
if not SpiderTools()._validate_url(url):
return f"# URL: {url}\n[Blocked: URL is not allowed]"
import urllib.request
req = urllib.request.Request(
@@ -36,30 +36,6 @@ def _safe_getattr(obj, name, *default):
return getattr(obj, name, *default) if default else getattr(obj, name)
_SANDBOX_BLOCKED_ATTRS = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
'__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
_SANDBOX_BLOCKED_CALLS = frozenset({
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir', 'vars',
})
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
@@ -72,6 +48,23 @@ def _validate_code_ast(code: str):
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -79,26 +72,28 @@ def _validate_code_ast(code: str):
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _SANDBOX_BLOCKED_ATTRS:
if node.attr in _blocked_attrs:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins (bare name or attribute access)
# Block calls to dangerous builtins by name
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
return f"Call to '{func.id}' is not allowed"
if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
return f"Call to '{func.attr}' is not allowed"
# Block dangerous constants (strings containing dunders)
# Fallback for Python 3.7 ast.Str
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
if any(attr in node.value for attr in _blocked_attrs):
return f"String constant contains restricted attribute name"
elif type(node).__name__ == 'Str':
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
return f"String constant contains restricted attribute name"
return None
@@ -117,16 +112,7 @@ def _execute_code_sandboxed(
"""
if limits is None:
limits = ResourceLimits.minimal()
ast_error = _validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False,
}
try:
# Create temporary file for the code
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
@@ -165,8 +151,21 @@ def safe_execute():
}}
# Block dangerous patterns
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
}}
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -183,20 +182,14 @@ def safe_execute():
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
"success": False
}}
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in blocked_calls:
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{func.id}}' is not allowed",
"success": False
}}
if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{func.attr}}' is not allowed",
"stderr": f"Call to '{{node.func.id}}' is not allowed",
"success": False
}}
if isinstance(node, ast.Constant) and isinstance(node.value, str):
@@ -488,7 +481,7 @@ def _execute_code_direct(
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', '__self__', 'globals(', 'locals(', 'vars('
'__class__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
@@ -184,41 +184,6 @@ class ToolRegistry:
return available
def list_tools_with_allowed_filter(self, context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS environment variable.
This applies the canonical ALLOWED_TOOLS filter to prevent tool
name collisions in multi-environment agent systems.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
try:
# Lazy import to avoid circular dependencies
from ..allowed_tools_filter import filter_tools_with_allowed_tools
# Get all available tool names
all_tools = self.list_tools()
# Apply ALLOWED_TOOLS filter with diagnostics
filtered_tools = filter_tools_with_allowed_tools(all_tools, log_diagnostics=True)
return sorted(filtered_tools)
except ImportError:
logging.warning("ALLOWED_TOOLS filter not available, returning all tools")
return self.list_tools()
except ValueError:
# Preserve strict ALLOWED_TOOLS semantics (e.g., CI unknown tools, empty value)
raise
except Exception as e:
logging.error("Error applying ALLOWED_TOOLS filter: %s", e)
# Fallback to all tools on error
return self.list_tools()
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
"""Get all registered tools as a dict. Thread-safe."""
with self._lock:
@@ -416,24 +381,3 @@ def list_available_tools(context: Optional[Dict[str, Any]] = None) -> List[Union
List of available tools
"""
return get_registry().list_available_tools(context)
def list_tools_with_allowed_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS from the global registry.
This is the canonical entry point for applying ALLOWED_TOOLS filtering
across the PraisonAI ecosystem.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
return get_registry().list_tools_with_allowed_filter(context)
# Backward compatibility alias
def list_tools_with_hermes_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""Backward compatibility alias for list_tools_with_allowed_filter."""
return list_tools_with_allowed_filter(context)
@@ -11,8 +11,6 @@ content = scrape_page("https://example.com")
"""
import logging
import ipaddress
import socket
from typing import List, Dict, Union, Optional, Any
from importlib import util
import json
@@ -22,48 +20,6 @@ import os
import hashlib
import time
def _host_is_blocked(hostname: str) -> bool:
"""Return True when hostname resolves to loopback/private/internal targets."""
if not hostname:
return True
host = hostname.lower().rstrip(".")
if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
return True
if host in ("169.254.169.254", "metadata.google.internal"):
return True
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
return True
def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
return bool(
ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
)
if host.isdigit():
try:
return _ip_blocked(ipaddress.ip_address(int(host)))
except (ValueError, OverflowError):
return True
if host.startswith("0x"):
try:
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
except (ValueError, OverflowError):
return True
try:
return _ip_blocked(ipaddress.ip_address(host))
except ValueError:
pass
try:
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
except OSError:
pass
return False
class SpiderTools:
"""Tools for web scraping and crawling."""
@@ -103,9 +59,31 @@ class SpiderTools:
if not parsed.hostname:
return False
if _host_is_blocked(parsed.hostname):
# Reject local/internal addresses
hostname = parsed.hostname.lower()
# Block localhost and loopback
if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
return False
# Block private IP ranges
import ipaddress
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
return False
except ValueError:
# Not an IP address, continue with domain validation
pass
# Block common internal domains
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
return False
# Block metadata service endpoints
if hostname in ['169.254.169.254', 'metadata.google.internal']:
return False
return True
except Exception:
@@ -1,41 +1,126 @@
"""
A2UI integration via Google's a2ui-agent-sdk (optional).
A2UI (Agent-to-User Interface) Protocol Integration for PraisonAI Agents
Declarative agent UI uses the official SDK — not reimplemented in PraisonAI core.
Install: pip install praisonaiagents[a2ui]
This module provides A2UI protocol support, enabling PraisonAI Agents
to generate rich, interactive UIs declaratively via JSON.
A2UI is Google's open standard that allows agents to "speak UI" by sending
declarative JSON describing UI components, which clients then render natively.
Usage:
from praisonaiagents.ui.a2ui import (
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Component types
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
# Extension helpers
create_a2ui_part,
is_a2ui_part,
)
"""
from praisonaiagents.ui.a2ui.adapter import (
from praisonaiagents.ui.a2ui.types import (
# Data binding
PathBinding,
resolve_string_or_path,
# Action types
ActionContext,
Action,
# Children types
ChildrenTemplate,
# Component types
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Data part
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
A2UI_MIME_TYPE,
A2UI_EXTENSION_URI,
STANDARD_CATALOG_ID,
create_a2ui_part,
generate_a2ui_system_prompt,
get_schema_manager,
is_a2ui_part,
parse_a2ui_response,
get_a2ui_agent_extension,
AgentExtension,
)
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.templates import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
)
__all__ = [
"A2UI",
# Data binding
"PathBinding",
"resolve_string_or_path",
# Action types
"ActionContext",
"Action",
# Children types
"ChildrenTemplate",
# Component types
"TextComponent",
"ImageComponent",
"IconComponent",
"ButtonComponent",
"CardComponent",
"RowComponent",
"ColumnComponent",
"ListComponent",
"TextFieldComponent",
"CheckBoxComponent",
"SliderComponent",
"DateTimeInputComponent",
# Message types
"CreateSurfaceMessage",
"UpdateComponentsMessage",
"UpdateDataModelMessage",
"DeleteSurfaceMessage",
# Data part
"A2UIDataPart",
# Extension
"A2UI_MIME_TYPE",
"A2UI_EXTENSION_URI",
"STANDARD_CATALOG_ID",
"create_a2ui_part",
"generate_a2ui_system_prompt",
"get_schema_manager",
"is_a2ui_part",
"parse_a2ui_response",
"get_a2ui_agent_extension",
"AgentExtension",
# Surface builder
"Surface",
# Agent wrapper
"A2UIAgent",
# Templates
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
]
class A2UI:
"""
Optional A2UI facade (lazy — imports a2ui-agent-sdk on first use).
Example:
from praisonaiagents.ui import A2UI
part = A2UI.create_part({"createSurface": {...}})
prompt = A2UI.system_prompt("You are a helpful assistant.")
"""
create_part = staticmethod(create_a2ui_part)
is_part = staticmethod(is_a2ui_part)
parse_response = staticmethod(parse_a2ui_response)
schema_manager = staticmethod(get_schema_manager)
system_prompt = staticmethod(generate_a2ui_system_prompt)
@@ -1,89 +0,0 @@
"""
Thin adapter over Google's a2ui-agent-sdk (optional dependency).
Install: pip install praisonaiagents[a2ui]
Spec: https://github.com/google/A2UI
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
_A2UI_INSTALL_HINT = (
"A2UI support requires the optional dependency. "
"Install with: pip install praisonaiagents[a2ui]"
)
def _import(name: str):
try:
import importlib
return importlib.import_module(name)
except ImportError as exc:
raise ImportError(
f"{_A2UI_INSTALL_HINT} "
f"(failed to import '{name}'; "
"if already installed, ensure a2a-sdk>=0.3.0,<1.0.0 and not a2a-sdk 1.x)"
) from exc
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> Any:
"""Wrap A2UI payload for A2A transport (MIME: application/json+a2ui)."""
parts = _import("a2ui.a2a.parts")
return parts.create_a2ui_part(a2ui_data)
def is_a2ui_part(part: Any) -> bool:
"""Return True if an A2A part contains A2UI data."""
parts = _import("a2ui.a2a.parts")
return parts.is_a2ui_part(part)
def parse_a2ui_response(text: str) -> List[Any]:
"""Parse and split LLM text into A2A parts (text + validated A2UI JSON)."""
parser = _import("a2ui.parser.parser")
return parser.parse_response(text)
def get_schema_manager(
version: str = "0.9",
catalogs: Optional[List[Any]] = None,
accepts_inline_catalogs: bool = False,
) -> Any:
"""Create an A2uiSchemaManager for LLM system prompts and validation."""
constants = _import("a2ui.schema.constants")
manager = _import("a2ui.schema.manager")
basic = _import("a2ui.basic_catalog.provider")
version_const = constants.VERSION_0_9 if version == "0.9" else version
catalog_configs = catalogs
if catalog_configs is None:
catalog_configs = [basic.BasicCatalog.get_config(version=version_const)]
return manager.A2uiSchemaManager(
version=version_const,
catalogs=catalog_configs,
accepts_inline_catalogs=accepts_inline_catalogs,
)
def generate_a2ui_system_prompt(
role_description: str,
workflow_description: str = "",
ui_description: str = "",
*,
version: str = "0.9",
include_schema: bool = True,
include_examples: bool = True,
) -> str:
"""Build an LLM system prompt with embedded A2UI schema and examples."""
schema_manager = get_schema_manager(version=version)
return schema_manager.generate_system_prompt(
role_description=role_description,
workflow_description=workflow_description,
ui_description=ui_description,
include_schema=include_schema,
include_examples=include_examples,
)
@@ -0,0 +1,358 @@
"""
A2UI Agent Integration
Provides A2UIAgent wrapper for generating A2UI responses from PraisonAI agents.
"""
import uuid
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import (
PathBinding,
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
create_a2ui_part,
STANDARD_CATALOG_ID,
)
if TYPE_CHECKING:
from praisonaiagents import Agent
class A2UIAgent:
"""
A2UI wrapper for PraisonAI agents.
Provides methods to generate A2UI responses from agent interactions.
Example:
>>> from praisonaiagents import Agent
>>> from praisonaiagents.ui.a2ui import A2UIAgent
>>>
>>> agent = Agent(name="Assistant", role="Helper", goal="Help users")
>>> a2ui_agent = A2UIAgent(agent=agent)
>>>
>>> # Render a simple text response
>>> messages = a2ui_agent.render_text("Hello, World!")
>>>
>>> # Render a list of items
>>> messages = a2ui_agent.render_list(
... title="Results",
... items=[{"name": "Item 1"}, {"name": "Item 2"}]
... )
"""
def __init__(
self,
agent: "Agent",
surface_id: Optional[str] = None,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize A2UIAgent wrapper.
Args:
agent: The PraisonAI Agent to wrap
surface_id: Unique ID for the UI surface (auto-generated if not provided)
catalog_id: The component catalog to use
"""
self.agent = agent
self.surface_id = surface_id or f"surface-{uuid.uuid4().hex[:8]}"
self.catalog_id = catalog_id
self._surface: Optional[Surface] = None
def get_surface(self) -> Surface:
"""
Get the underlying Surface object.
Creates a new Surface if one doesn't exist.
Returns:
The Surface object
"""
if self._surface is None:
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
def _reset_surface(self) -> Surface:
"""Reset and return a new Surface."""
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
# =========================================================================
# Rendering methods
# =========================================================================
def render_text(
self,
text: str,
title: Optional[str] = None,
usage_hint: str = "body",
) -> List[Dict[str, Any]]:
"""
Render a simple text response as A2UI.
Args:
text: The text content to display
title: Optional title heading
usage_hint: Text style hint (h1, h2, body, caption)
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
children = []
if title:
surface.text(id="title", text=title, usage_hint="h1")
children.append("title")
surface.text(id="content", text=text, usage_hint=usage_hint)
children.append("content")
# Add root column
surface.column(id="root", children=children)
return surface.to_messages()
def render_list(
self,
title: str,
items: List[Dict[str, Any]],
item_title_key: str = "title",
item_description_key: str = "description",
item_image_key: Optional[str] = None,
item_action: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Render a list of items as A2UI cards.
Args:
title: List title
items: List of item dictionaries
item_title_key: Key for item title in each dict
item_description_key: Key for item description
item_image_key: Optional key for item image URL
item_action: Optional action name for item buttons
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="list-title", text=title, usage_hint="h1")
# Create card for each item
card_ids = []
for i, item in enumerate(items):
card_id = f"card-{i}"
content_id = f"card-content-{i}"
title_id = f"item-title-{i}"
desc_id = f"item-desc-{i}"
# Item title
surface.text(
id=title_id,
text=item.get(item_title_key, f"Item {i+1}"),
usage_hint="h3"
)
# Item description
surface.text(
id=desc_id,
text=item.get(item_description_key, ""),
usage_hint="body"
)
content_children = [title_id, desc_id]
# Optional action button
if item_action:
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item_action,
action_context=[{"key": "index", "value": i}],
primary=True
)
content_children.append(btn_id)
# Card content column
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# List column
surface.column(id="item-list", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "item-list"])
# Set data
surface.set_data("items", items)
return surface.to_messages()
def render_form(
self,
title: str,
fields: List[Dict[str, str]],
submit_action: str = "submit",
submit_label: str = "Submit",
) -> List[Dict[str, Any]]:
"""
Render a form as A2UI.
Args:
title: Form title
fields: List of field definitions with id, label, type
submit_action: Action name for submit button
submit_label: Label for submit button
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="form-title", text=title, usage_hint="h1")
# Fields
field_ids = []
form_data = {}
for field in fields:
field_id = field.get("id", f"field-{len(field_ids)}")
label = field.get("label", field_id)
field_type = field.get("type", "text")
surface.text_field(
id=field_id,
label=label,
text=PathBinding(path=f"/{field_id}"),
field_type=field_type,
)
field_ids.append(field_id)
form_data[field_id] = field.get("default", "")
# Submit button
surface.text(id="submit-text", text=submit_label)
# Build action context from fields
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form column
surface.column(id="form-fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "form-fields"])
# Set initial form data
for key, value in form_data.items():
surface.set_data(key, value)
return surface.to_messages()
# =========================================================================
# Agent interaction methods
# =========================================================================
async def chat_async(self, message: str) -> str:
"""
Send a message to the agent and get a response.
Args:
message: The message to send
Returns:
The agent's response text
"""
# Use the agent's chat method
if hasattr(self.agent, 'chat'):
response = self.agent.chat(message)
return response
elif hasattr(self.agent, 'run'):
response = self.agent.run(message)
return response
else:
raise NotImplementedError("Agent does not have chat or run method")
def chat(self, message: str) -> str:
"""
Send a message to the agent and get a response (sync).
Args:
message: The message to send
Returns:
The agent's response text
"""
if hasattr(self.agent, 'chat'):
return self.agent.chat(message)
elif hasattr(self.agent, 'run'):
return self.agent.run(message)
else:
raise NotImplementedError("Agent does not have chat or run method")
# =========================================================================
# Export methods
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Get the current surface as A2UI messages.
Returns:
List of A2UI message dictionaries
"""
return self.get_surface().to_messages()
def to_json(self, indent: Optional[int] = None) -> str:
"""
Get the current surface as JSON string.
Args:
indent: JSON indentation level
Returns:
JSON string of A2UI messages
"""
return self.get_surface().to_json(indent=indent)
def to_a2a_part(self) -> A2UIDataPart:
"""
Get the current surface as an A2A DataPart.
Returns:
A2UIDataPart with A2UI MIME type
"""
messages = self.to_messages()
# For A2A, we send messages as a list
return create_a2ui_part({"messages": messages})
@@ -0,0 +1,115 @@
"""
A2UI Extension for A2A Protocol
Provides helpers for integrating A2UI with the A2A (Agent-to-Agent) protocol.
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# =============================================================================
# Constants
# =============================================================================
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.9"
A2UI_MIME_TYPE = "application/json+a2ui"
STANDARD_CATALOG_ID = "https://raw.githubusercontent.com/google/A2UI/refs/heads/main/specification/0.9/json/standard_catalog_definition.json"
# =============================================================================
# Agent Extension Type
# =============================================================================
@dataclass
class AgentExtension:
"""
A2A Agent Extension configuration.
Describes an extension that an agent supports.
"""
uri: str
description: str = ""
params: Optional[Dict[str, Any]] = None
# =============================================================================
# Helper Functions
# =============================================================================
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> A2UIDataPart:
"""
Create an A2UI data part for A2A integration.
Wraps A2UI data in an A2UIDataPart with the appropriate MIME type.
Args:
a2ui_data: The A2UI data dictionary (e.g., createSurface, updateComponents)
Returns:
An A2UIDataPart with the A2UI MIME type in metadata
Example:
>>> part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "..."}})
>>> part.metadata["mimeType"]
'application/json+a2ui'
"""
return A2UIDataPart(
data=a2ui_data,
metadata={"mimeType": A2UI_MIME_TYPE}
)
def is_a2ui_part(part: A2UIDataPart) -> bool:
"""
Check if a data part contains A2UI data.
Args:
part: The data part to check
Returns:
True if the part contains A2UI data (has A2UI MIME type), False otherwise
Example:
>>> part = create_a2ui_part({"createSurface": {...}})
>>> is_a2ui_part(part)
True
"""
if not isinstance(part, A2UIDataPart):
return False
if part.metadata is None:
return False
return part.metadata.get("mimeType") == A2UI_MIME_TYPE
def get_a2ui_agent_extension(
accepts_inline_custom_catalog: bool = False,
) -> AgentExtension:
"""
Create the A2UI AgentExtension configuration.
Returns an AgentExtension that can be added to an A2A Agent Card
to indicate A2UI support.
Args:
accepts_inline_custom_catalog: Whether the agent accepts inline custom catalogs
Returns:
The configured A2UI AgentExtension
Example:
>>> ext = get_a2ui_agent_extension()
>>> ext.uri
'https://a2ui.org/a2a-extension/a2ui/v0.9'
"""
params: Optional[Dict[str, Any]] = None
if accepts_inline_custom_catalog:
params = {"acceptsInlineCustomCatalog": True}
return AgentExtension(
uri=A2UI_EXTENSION_URI,
description="Provides agent-driven UI using the A2UI JSON format.",
params=params
)
@@ -0,0 +1,364 @@
"""
A2UI Surface Builder
Provides a fluent API for building A2UI surfaces with components and data.
"""
import json
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
from praisonaiagents.ui.a2ui.types import (
Component,
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
Action,
ActionContext,
PathBinding,
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
StringOrPath,
ChildrenType,
)
class Surface:
"""
A2UI Surface builder for creating rich agent-generated UIs.
Provides a fluent API for adding components and data, then
generating the A2UI JSON messages.
Example:
>>> surface = Surface(surface_id="main")
>>> surface.text(id="title", text="Hello World", usage_hint="h1")
>>> surface.button(id="submit", child="submit-text", action_name="submit")
>>> surface.set_data("title", "Hello World")
>>> messages = surface.to_messages()
"""
def __init__(
self,
surface_id: str,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize a new Surface.
Args:
surface_id: Unique identifier for this surface
catalog_id: The component catalog to use (defaults to standard catalog)
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.components: List[Component] = []
self.data: Dict[str, Any] = {}
def add(self, component: Component) -> "Surface":
"""
Add a component to the surface.
Args:
component: The component to add
Returns:
self for method chaining
"""
self.components.append(component)
return self
def set_data(self, key: str, value: Any) -> "Surface":
"""
Set a value in the data model.
Args:
key: The key in the data model
value: The value to set
Returns:
self for method chaining
"""
self.data[key] = value
return self
# =========================================================================
# Helper methods for common components
# =========================================================================
def text(
self,
id: str,
text: StringOrPath,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Text component.
Args:
id: Component ID
text: Text content or path binding
usage_hint: Style hint (h1, h2, h3, body, caption)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextComponent(
id=id,
text=text,
usage_hint=usage_hint,
weight=weight,
))
def image(
self,
id: str,
url: StringOrPath,
fit: Optional[str] = None,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add an Image component.
Args:
id: Component ID
url: Image URL or path binding
fit: Image fit (contain, cover, fill, none, scale-down)
usage_hint: Size hint (icon, avatar, smallFeature, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ImageComponent(
id=id,
url=url,
fit=fit,
usage_hint=usage_hint,
weight=weight,
))
def button(
self,
id: str,
child: str,
action_name: Optional[str] = None,
action_context: Optional[List[Dict[str, Any]]] = None,
primary: bool = False,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Button component.
Args:
id: Component ID
child: Child component ID (usually a Text component)
action_name: Name of the action to trigger
action_context: List of {key, value} dicts for action context
primary: Whether this is a primary button
weight: Flex weight
Returns:
self for method chaining
"""
action = None
if action_name:
context = []
if action_context:
for ctx in action_context:
value = ctx.get("value")
if isinstance(value, dict) and "path" in value:
value = PathBinding(path=value["path"])
context.append(ActionContext(key=ctx["key"], value=value))
action = Action(name=action_name, context=context)
return self.add(ButtonComponent(
id=id,
child=child,
action=action,
primary=primary,
weight=weight,
))
def card(
self,
id: str,
child: str,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Card component.
Args:
id: Component ID
child: Child component ID
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(CardComponent(
id=id,
child=child,
weight=weight,
))
def row(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Row (horizontal layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(RowComponent(
id=id,
children=children,
weight=weight,
))
def column(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Column (vertical layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ColumnComponent(
id=id,
children=children,
weight=weight,
))
def list(
self,
id: str,
children: ChildrenType,
direction: str = "vertical",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a List component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate for dynamic lists
direction: "vertical" or "horizontal"
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ListComponent(
id=id,
children=children,
direction=direction,
weight=weight,
))
def text_field(
self,
id: str,
label: StringOrPath,
text: StringOrPath,
field_type: str = "text",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a TextField input component.
Args:
id: Component ID
label: Field label
text: Text value or path binding
field_type: Input type (text, number, email, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextFieldComponent(
id=id,
label=label,
text=text,
field_type=field_type,
weight=weight,
))
# =========================================================================
# Message generation
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for this surface.
Returns:
List of A2UI message dictionaries
"""
messages: List[Dict[str, Any]] = []
# 1. CreateSurface message
create_msg = CreateSurfaceMessage(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
messages.append(create_msg.to_dict())
# 2. UpdateComponents message (if we have components)
if self.components:
update_msg = UpdateComponentsMessage(
surface_id=self.surface_id,
components=self.components,
)
messages.append(update_msg.to_dict())
# 3. UpdateDataModel message (if we have data)
if self.data:
data_msg = UpdateDataModelMessage(
surface_id=self.surface_id,
value=self.data,
)
messages.append(data_msg.to_dict())
return messages
def to_json(self, indent: Optional[int] = None) -> str:
"""
Generate A2UI messages as a JSON string.
Args:
indent: JSON indentation level (None for compact)
Returns:
JSON string of A2UI messages
"""
return json.dumps(self.to_messages(), indent=indent)
@@ -0,0 +1,17 @@
"""
A2UI Templates
Pre-built UI templates for common use cases.
"""
from praisonaiagents.ui.a2ui.templates.chat import ChatTemplate
from praisonaiagents.ui.a2ui.templates.list import ListTemplate
from praisonaiagents.ui.a2ui.templates.form import FormTemplate
from praisonaiagents.ui.a2ui.templates.dashboard import DashboardTemplate
__all__ = [
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
]
@@ -0,0 +1,122 @@
"""
Chat Template for A2UI
Provides a conversational chat UI template.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ChatTemplate:
"""
Chat UI template for conversational interfaces.
Creates a chat-style UI with user and agent messages.
Example:
>>> template = ChatTemplate(surface_id="chat")
>>> template.add_user_message("Hello!")
>>> template.add_agent_message("Hi there! How can I help?")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "chat",
catalog_id: str = STANDARD_CATALOG_ID,
title: Optional[str] = None,
):
"""
Initialize ChatTemplate.
Args:
surface_id: Unique ID for the surface
catalog_id: Component catalog to use
title: Optional title for the chat
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.title = title
self.messages: List[Dict[str, Any]] = []
def add_user_message(self, content: str) -> "ChatTemplate":
"""
Add a user message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "user",
"content": content,
})
return self
def add_agent_message(self, content: str) -> "ChatTemplate":
"""
Add an agent message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "agent",
"content": content,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the chat.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
children = []
# Title if provided
if self.title:
surface.text(id="chat-title", text=self.title, usage_hint="h1")
children.append("chat-title")
# Message components
msg_ids = []
for i, msg in enumerate(self.messages):
msg_id = f"msg-{i}"
role = msg["role"]
content = msg["content"]
# Style based on role
if role == "user":
surface.text(id=msg_id, text=f"You: {content}", usage_hint="body")
else:
surface.text(id=msg_id, text=f"Agent: {content}", usage_hint="body")
msg_ids.append(msg_id)
# Messages column
if msg_ids:
surface.column(id="messages", children=msg_ids)
children.append("messages")
# Root column
surface.column(id="root", children=children)
# Set data
surface.set_data("messages", self.messages)
return surface.to_messages()
@@ -0,0 +1,128 @@
"""
Dashboard Template for A2UI
Provides a dashboard UI template with multiple panels.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class DashboardTemplate:
"""
Dashboard UI template with multiple panels.
Creates a dashboard layout with cards for each panel.
Example:
>>> template = DashboardTemplate(surface_id="dashboard", title="My Dashboard")
>>> template.add_panel(id="stats", title="Statistics", content="100 items")
>>> template.add_panel(id="chart", title="Chart", content="[Chart here]")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "dashboard",
title: str = "Dashboard",
catalog_id: str = STANDARD_CATALOG_ID,
columns: int = 2,
):
"""
Initialize DashboardTemplate.
Args:
surface_id: Unique ID for the surface
title: Dashboard title
catalog_id: Component catalog to use
columns: Number of columns in the grid
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.columns = columns
self.panels: List[Dict[str, Any]] = []
def add_panel(
self,
id: str,
title: str,
content: str,
weight: Optional[float] = None,
) -> "DashboardTemplate":
"""
Add a panel to the dashboard.
Args:
id: Panel ID
title: Panel title
content: Panel content text
weight: Optional flex weight
Returns:
self for chaining
"""
self.panels.append({
"id": id,
"title": title,
"content": content,
"weight": weight,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the dashboard.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="dashboard-title", text=self.title, usage_hint="h1")
# Create cards for each panel
card_ids = []
for panel in self.panels:
panel_id = panel["id"]
content_id = f"{panel_id}-content"
title_id = f"{panel_id}-title"
text_id = f"{panel_id}-text"
# Panel title
surface.text(id=title_id, text=panel["title"], usage_hint="h3")
# Panel content
surface.text(id=text_id, text=panel["content"], usage_hint="body")
# Panel content column
surface.column(id=content_id, children=[title_id, text_id])
# Panel card
surface.card(id=panel_id, child=content_id, weight=panel.get("weight"))
card_ids.append(panel_id)
# Arrange panels in rows based on columns setting
row_ids = []
for i in range(0, len(card_ids), self.columns):
row_cards = card_ids[i:i + self.columns]
row_id = f"row-{i // self.columns}"
surface.row(id=row_id, children=row_cards)
row_ids.append(row_id)
# Panels container
surface.column(id="panels", children=row_ids)
# Root column
surface.column(id="root", children=["dashboard-title", "panels"])
# Set data
surface.set_data("panels", self.panels)
return surface.to_messages()
@@ -0,0 +1,205 @@
"""
Form Template for A2UI
Provides a form UI template for data collection.
"""
from typing import Any, Dict, List
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import PathBinding
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class FormTemplate:
"""
Form UI template for data collection.
Creates a form with various input fields and a submit button.
Example:
>>> template = FormTemplate(surface_id="form", title="Contact")
>>> template.add_text_field(id="name", label="Name")
>>> template.add_email_field(id="email", label="Email")
>>> template.set_submit_action("submit_form", "Send")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "form",
title: str = "Form",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize FormTemplate.
Args:
surface_id: Unique ID for the surface
title: Form title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.fields: List[Dict[str, Any]] = []
self.submit_action = "submit"
self.submit_label = "Submit"
def add_text_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add a text input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "text",
"default": default,
"required": required,
})
return self
def add_number_field(
self,
id: str,
label: str,
default: float = 0,
required: bool = False,
) -> "FormTemplate":
"""
Add a number input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "number",
"default": default,
"required": required,
})
return self
def add_email_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add an email input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "email",
"default": default,
"required": required,
})
return self
def set_submit_action(
self,
action: str,
label: str = "Submit",
) -> "FormTemplate":
"""
Set the submit button action.
Args:
action: Action name
label: Button label
Returns:
self for chaining
"""
self.submit_action = action
self.submit_label = label
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the form.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="form-title", text=self.title, usage_hint="h1")
# Fields
field_ids = []
for field in self.fields:
field_id = field["id"]
surface.text_field(
id=field_id,
label=field["label"],
text=PathBinding(path=f"/{field_id}"),
field_type=field["type"],
)
field_ids.append(field_id)
# Set default value
surface.set_data(field_id, field.get("default", ""))
# Submit button
surface.text(id="submit-text", text=self.submit_label)
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in self.fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=self.submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form fields column
surface.column(id="fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "fields"])
return surface.to_messages()
@@ -0,0 +1,147 @@
"""
List Template for A2UI
Provides a list UI template for displaying items.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ListTemplate:
"""
List UI template for displaying items.
Creates a list of cards with title, description, and optional image.
Example:
>>> template = ListTemplate(surface_id="list", title="Results")
>>> template.add_item(title="Item 1", description="First item")
>>> template.add_item(title="Item 2", description="Second item")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "list",
title: str = "Items",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize ListTemplate.
Args:
surface_id: Unique ID for the surface
title: List title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.items: List[Dict[str, Any]] = []
def add_item(
self,
title: str,
description: str = "",
image_url: Optional[str] = None,
action: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> "ListTemplate":
"""
Add an item to the list.
Args:
title: Item title
description: Item description
image_url: Optional image URL
action: Optional action name for item button
metadata: Optional additional metadata
Returns:
self for chaining
"""
item = {
"title": title,
"description": description,
}
if image_url:
item["image_url"] = image_url
if action:
item["action"] = action
if metadata:
item.update(metadata)
self.items.append(item)
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the list.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="list-title", text=self.title, usage_hint="h1")
# Create cards for each item
card_ids = []
for i, item in enumerate(self.items):
card_id = f"card-{i}"
content_id = f"content-{i}"
title_id = f"title-{i}"
desc_id = f"desc-{i}"
# Item title
surface.text(id=title_id, text=item["title"], usage_hint="h3")
# Item description
surface.text(id=desc_id, text=item.get("description", ""), usage_hint="body")
content_children = [title_id, desc_id]
# Optional image
if item.get("image_url"):
img_id = f"img-{i}"
surface.image(id=img_id, url=item["image_url"], usage_hint="smallFeature")
content_children.insert(0, img_id)
# Optional action button
if item.get("action"):
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item["action"],
action_context=[{"key": "index", "value": i}],
primary=True,
)
content_children.append(btn_id)
# Card content
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# Items column
surface.column(id="items", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "items"])
# Set data
surface.set_data("items", self.items)
return surface.to_messages()
@@ -0,0 +1,523 @@
"""
A2UI Protocol Types
Pydantic-style classes for the A2UI (Agent-to-User Interface) protocol.
Based on the A2UI Specification v0.9.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from enum import Enum
# =============================================================================
# Data Binding Types
# =============================================================================
@dataclass
class PathBinding:
"""
A reference to a value in the data model.
Used for data binding in A2UI components.
"""
path: str
def to_dict(self) -> Dict[str, str]:
return {"path": self.path}
# Type alias for values that can be literal or path-bound
StringOrPath = Union[str, PathBinding]
NumberOrPath = Union[int, float, PathBinding]
BooleanOrPath = Union[bool, PathBinding]
def resolve_string_or_path(value: StringOrPath) -> Union[str, Dict[str, str]]:
"""Resolve a StringOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_number_or_path(value: NumberOrPath) -> Union[int, float, Dict[str, str]]:
"""Resolve a NumberOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_boolean_or_path(value: BooleanOrPath) -> Union[bool, Dict[str, str]]:
"""Resolve a BooleanOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
# =============================================================================
# Action Types
# =============================================================================
@dataclass
class ActionContext:
"""
A key-value pair for action context.
Used to pass data from UI to action handlers.
"""
key: str
value: Union[str, int, float, bool, PathBinding]
def to_dict(self) -> Dict[str, Any]:
if isinstance(self.value, PathBinding):
return {"key": self.key, "value": self.value.to_dict()}
return {"key": self.key, "value": self.value}
@dataclass
class Action:
"""
An action triggered by a UI component (e.g., button click).
"""
name: str
context: List[ActionContext] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"context": [c.to_dict() for c in self.context]
}
# =============================================================================
# Children Types
# =============================================================================
@dataclass
class ChildrenTemplate:
"""
A template for generating dynamic children from a data model list.
"""
component_id: str
path: str
def to_dict(self) -> Dict[str, str]:
return {
"componentId": self.component_id,
"path": self.path
}
# Type alias for children - either explicit list or template
ChildrenType = Union[List[str], ChildrenTemplate]
def resolve_children(children: ChildrenType) -> Union[List[str], Dict[str, str]]:
"""Resolve children to serialized form."""
if isinstance(children, ChildrenTemplate):
return children.to_dict()
return children
# =============================================================================
# Component Types
# =============================================================================
class UsageHint(str, Enum):
"""Text usage hints for styling."""
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
CAPTION = "caption"
BODY = "body"
class ImageFit(str, Enum):
"""Image fit options."""
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
NONE = "none"
SCALE_DOWN = "scale-down"
class ImageUsageHint(str, Enum):
"""Image usage hints."""
ICON = "icon"
AVATAR = "avatar"
SMALL_FEATURE = "smallFeature"
MEDIUM_FEATURE = "mediumFeature"
LARGE_FEATURE = "largeFeature"
HEADER = "header"
@dataclass
class TextComponent:
"""Text display component."""
id: str
text: StringOrPath
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Text",
"text": resolve_string_or_path(self.text)
}
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ImageComponent:
"""Image display component."""
id: str
url: StringOrPath
fit: Optional[str] = None
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Image",
"url": resolve_string_or_path(self.url)
}
if self.fit:
result["fit"] = self.fit
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class IconComponent:
"""Icon display component."""
id: str
name: StringOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Icon",
"name": resolve_string_or_path(self.name)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ButtonComponent:
"""Button component with action."""
id: str
child: str
action: Optional[Action] = None
primary: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Button",
"child": self.child
}
if self.primary:
result["primary"] = True
if self.action:
result["action"] = self.action.to_dict()
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CardComponent:
"""Card container component."""
id: str
child: str
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Card",
"child": self.child
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class RowComponent:
"""Horizontal layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Row",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ColumnComponent:
"""Vertical layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Column",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ListComponent:
"""List component for repeating items."""
id: str
children: ChildrenType
direction: str = "vertical"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "List",
"direction": self.direction,
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class TextFieldComponent:
"""Text input field component."""
id: str
label: StringOrPath
text: StringOrPath
field_type: str = "text"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "TextField",
"label": resolve_string_or_path(self.label),
"text": resolve_string_or_path(self.text),
"type": self.field_type
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CheckBoxComponent:
"""Checkbox input component."""
id: str
label: StringOrPath
checked: BooleanOrPath = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "CheckBox",
"label": resolve_string_or_path(self.label),
"checked": resolve_boolean_or_path(self.checked)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class SliderComponent:
"""Slider input component."""
id: str
min_value: NumberOrPath
max_value: NumberOrPath
value: NumberOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Slider",
"min": resolve_number_or_path(self.min_value),
"max": resolve_number_or_path(self.max_value),
"value": resolve_number_or_path(self.value)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class DateTimeInputComponent:
"""Date/time input component."""
id: str
label: StringOrPath
value: StringOrPath
enable_date: bool = True
enable_time: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "DateTimeInput",
"label": resolve_string_or_path(self.label),
"value": resolve_string_or_path(self.value),
"enableDate": self.enable_date,
"enableTime": self.enable_time
}
if self.weight is not None:
result["weight"] = self.weight
return result
# Type alias for any component
Component = Union[
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
]
# =============================================================================
# Message Types
# =============================================================================
@dataclass
class CreateSurfaceMessage:
"""
Message to create a new UI surface.
Signals the client to create a new surface and begin rendering it.
"""
surface_id: str
catalog_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"createSurface": {
"surfaceId": self.surface_id,
"catalogId": self.catalog_id
}
}
@dataclass
class UpdateComponentsMessage:
"""
Message to update surface components.
Updates a surface with a new set of components.
"""
surface_id: str
components: List[Component]
def to_dict(self) -> Dict[str, Any]:
return {
"updateComponents": {
"surfaceId": self.surface_id,
"components": [c.to_dict() for c in self.components]
}
}
@dataclass
class UpdateDataModelMessage:
"""
Message to update the data model.
Updates the data model for an existing surface.
"""
surface_id: str
value: Any
path: str = "/"
op: str = "replace"
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"updateDataModel": {
"surfaceId": self.surface_id,
"path": self.path,
"op": self.op,
"value": self.value
}
}
return result
@dataclass
class DeleteSurfaceMessage:
"""
Message to delete a surface.
Signals the client to delete the surface.
"""
surface_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"deleteSurface": {
"surfaceId": self.surface_id
}
}
# =============================================================================
# Data Part Type
# =============================================================================
@dataclass
class A2UIDataPart:
"""
A2UI data part for A2A integration.
Wraps A2UI data with the appropriate MIME type.
"""
data: Dict[str, Any]
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {"data": self.data}
if self.metadata:
result["metadata"] = self.metadata
return result
@@ -4,8 +4,6 @@ AG-UI Event Streaming
Provides utilities for streaming PraisonAI agent responses as AG-UI events.
"""
import asyncio
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
@@ -258,47 +256,6 @@ async def async_stream_response(
yield create_run_error_event(str(e))
def stream_event_to_agui_events(
event: Any,
message_id: str,
buffer: EventBuffer,
) -> List[BaseEvent]:
"""Convert a PraisonAI StreamEvent into zero or more AG-UI events."""
from praisonaiagents.streaming.events import StreamEventType
if event.type == StreamEventType.DELTA_TEXT and event.content:
if not buffer.current_text_message_id:
buffer.start_text_message()
return [
TextMessageStartEvent(message_id=buffer.current_text_message_id, role="assistant"),
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
),
]
return [
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
)
]
if event.type == StreamEventType.TOOL_CALL_START and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
tool_name = event.tool_call.get("name", "tool")
arguments = event.tool_call.get("arguments", {})
args_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
parent_id = buffer.get_parent_message_id_for_tool_call() or message_id
buffer.start_tool_call(tool_call_id)
return list(create_tool_call_events(tool_call_id, tool_name, args_str, parent_id))
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
buffer.end_tool_call(tool_call_id)
return [create_tool_result_event(tool_call_id, str(content), message_id)]
return []
async def async_stream_agent_response(
agent,
user_input: str,
@@ -321,63 +278,37 @@ async def async_stream_agent_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
buffer = EventBuffer()
message_id = str(uuid.uuid4())
text_started = False
text_ended = False
original_emitter = getattr(agent, "stream_emitter", None)
def on_stream_event(event: Any) -> None:
loop.call_soon_threadsafe(queue.put_nowait, ("stream", event))
if original_emitter is not None:
original_emitter.add_callback(on_stream_event)
async def run_chat() -> Any:
try:
if hasattr(agent, "achat"):
return await agent.achat(user_input, stream=True)
if hasattr(agent, "chat"):
return await loop.run_in_executor(
None, lambda: agent.chat(user_input, stream=True)
)
raise ValueError("Agent must have 'chat' or 'achat' method")
finally:
loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
chat_task = asyncio.create_task(run_chat())
try:
while True:
kind, payload = await queue.get()
if kind == "done":
break
for agui_event in stream_event_to_agui_events(payload, message_id, buffer):
if isinstance(agui_event, TextMessageStartEvent):
text_started = True
yield agui_event
response = await chat_task
if text_started and buffer.current_text_message_id and not text_ended:
yield TextMessageEndEvent(message_id=buffer.current_text_message_id)
text_ended = True
elif not text_started and response:
yield TextMessageStartEvent(message_id=message_id, role="assistant")
# Check if agent has async chat method
if hasattr(agent, 'achat'):
response = await agent.achat(user_input)
elif hasattr(agent, 'chat'):
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, user_input)
else:
raise ValueError("Agent must have 'chat' or 'achat' method")
# Emit text message events
yield TextMessageStartEvent(message_id=message_id, role="assistant")
if response:
yield TextMessageContentEvent(message_id=message_id, delta=str(response))
yield TextMessageEndEvent(message_id=message_id)
yield TextMessageEndEvent(message_id=message_id)
# Emit run finished
yield create_run_finished_event(thread_id, run_id)
except Exception as e:
yield create_run_error_event(str(e))
finally:
if original_emitter is not None:
original_emitter.remove_callback(on_stream_event)
async def async_stream_agents_response(
@@ -400,6 +331,8 @@ async def async_stream_agents_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
@@ -411,7 +344,7 @@ async def async_stream_agents_response(
result = await agents.astart(user_input)
elif hasattr(agents, 'start'):
# Run sync start in executor
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, agents.start, user_input)
else:
raise ValueError("Agents must have 'start' or 'astart' method")
+2 -20
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.37"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -101,22 +101,6 @@ crawl = [
"playwright>=1.47.0",
]
# Sandbox dependencies
sandbox = [
"e2b-code-interpreter>=1.0.0",
]
# Declarative agent UI (Google A2UI SDK — lazy loaded)
a2ui = [
"a2ui-agent-sdk>=0.2.1",
"a2a-sdk>=0.3.0,<1.0.0",
]
# Docker sandbox dependencies
sandbox-docker = [
"docker>=7.0.0",
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -128,12 +112,10 @@ all = [
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]",
"praisonaiagents[sandbox]",
"praisonaiagents[search]",
"praisonaiagents[crawl]",
"praisonaiagents[autonomy]",
"praisonaiagents[os]",
"praisonaiagents[a2ui]",
"praisonaiagents[os]"
]
[tool.setuptools.packages.find]
@@ -0,0 +1 @@
"""A2UI Tests Package."""
@@ -1,36 +0,0 @@
"""Tests for A2UI adapter (optional a2ui-agent-sdk dependency)."""
import pytest
class TestA2UIAdapterImport:
def test_a2ui_class_export(self):
from praisonaiagents.ui.a2ui import A2UI
assert A2UI is not None
assert hasattr(A2UI, "create_part")
def test_ui_lazy_a2ui_export(self):
from praisonaiagents.ui import A2UI
assert A2UI is not None
def test_missing_optional_dep_raises_clear_error(self):
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
# If installed, adapter imports should work
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "test"}})
assert part is not None
class TestA2UITool:
def test_send_a2ui_messages_requires_sdk(self):
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
if pytest.importorskip("a2ui", reason="skip without sdk"):
result = send_a2ui_messages(
messages=[{"createSurface": {"surfaceId": "x", "catalogId": "c"}}]
)
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
@@ -0,0 +1,202 @@
"""
Tests for A2UI Agent Integration
TDD: Write tests first, then implement the A2UIAgent class.
"""
import os
import pytest
# Skip tests if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIAgent:
"""Test A2UIAgent wrapper class."""
def test_a2ui_agent_creation(self):
"""Test A2UIAgent creation with an agent."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent)
assert a2ui_agent.agent is agent
assert a2ui_agent.surface_id is not None
def test_a2ui_agent_with_custom_surface_id(self):
"""Test A2UIAgent with custom surface ID."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent, surface_id="custom-surface")
assert a2ui_agent.surface_id == "custom-surface"
def test_render_text_response(self):
"""Test rendering a simple text response as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render a simple text response
messages = a2ui_agent.render_text("Hello, World!")
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
# Check createSurface
assert "createSurface" in messages[0]
# Check updateComponents has a text component
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
assert any(c.get("component") == "Text" for c in components)
def test_render_list_response(self):
"""Test rendering a list of items as A2UI cards."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
items = [
{"title": "Item 1", "description": "First item"},
{"title": "Item 2", "description": "Second item"},
]
messages = a2ui_agent.render_list(
title="My Items",
items=items,
item_title_key="title",
item_description_key="description"
)
assert isinstance(messages, list)
assert len(messages) >= 2
def test_render_form(self):
"""Test rendering a form as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
fields = [
{"id": "name", "label": "Name", "type": "text"},
{"id": "email", "label": "Email", "type": "text"},
]
messages = a2ui_agent.render_form(
title="Contact Form",
fields=fields,
submit_action="submit_form"
)
assert isinstance(messages, list)
assert len(messages) >= 2
# Check for TextField components
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
text_fields = [c for c in components if c.get("component") == "TextField"]
assert len(text_fields) >= 2
def test_get_surface(self):
"""Test getting the underlying Surface object."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.surface import Surface
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
surface = a2ui_agent.get_surface()
assert isinstance(surface, Surface)
assert surface.surface_id == a2ui_agent.surface_id
class TestA2UIAgentWithLLM:
"""Test A2UIAgent with actual LLM calls."""
@pytest.mark.integration
@pytest.mark.asyncio
async def test_chat_with_ui(self):
"""Test chat that returns A2UI response."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful UI responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# This should work with the agent's chat
response = await a2ui_agent.chat_async("Say hello")
assert response is not None
# Response should contain text
assert isinstance(response, str) or isinstance(response, dict)
class TestA2UIAgentIntegration:
"""Integration tests for A2UIAgent with A2A."""
def test_get_a2a_data_part(self):
"""Test getting A2UI as A2A DataPart."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as A2A DataPart
part = a2ui_agent.to_a2a_part()
assert part is not None
assert is_a2ui_part(part)
def test_to_json(self):
"""Test getting A2UI as JSON string."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
import json
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as JSON
json_str = a2ui_agent.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
@@ -0,0 +1,115 @@
"""
Tests for A2UI Extension Module
TDD: Write tests first, then implement the extension.
"""
class TestA2UIExtension:
"""Test A2UI extension helpers for A2A integration."""
def test_a2ui_mime_type(self):
"""Test A2UI MIME type constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_MIME_TYPE
assert A2UI_MIME_TYPE == "application/json+a2ui"
def test_a2ui_extension_uri(self):
"""Test A2UI extension URI constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_EXTENSION_URI
assert "a2ui" in A2UI_EXTENSION_URI.lower()
def test_create_a2ui_part(self):
"""Test create_a2ui_part wraps A2UI data in A2A DataPart."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, A2UI_MIME_TYPE
a2ui_data = {
"createSurface": {
"surfaceId": "main",
"catalogId": "https://a2ui.dev/standard"
}
}
part = create_a2ui_part(a2ui_data)
# Should return a Part with DataPart root
assert part is not None
assert hasattr(part, 'data')
assert part.data == a2ui_data
assert part.metadata is not None
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
def test_is_a2ui_part_true(self):
"""Test is_a2ui_part returns True for A2UI parts."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, is_a2ui_part
a2ui_data = {"createSurface": {"surfaceId": "main", "catalogId": "test"}}
part = create_a2ui_part(a2ui_data)
assert is_a2ui_part(part) is True
def test_is_a2ui_part_false(self):
"""Test is_a2ui_part returns False for non-A2UI parts."""
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# Create a regular data part without A2UI mime type
part = A2UIDataPart(data={"foo": "bar"}, metadata={"mimeType": "application/json"})
assert is_a2ui_part(part) is False
def test_get_a2ui_agent_extension(self):
"""Test get_a2ui_agent_extension returns AgentExtension config."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension()
assert ext is not None
assert hasattr(ext, 'uri')
assert "a2ui" in ext.uri.lower()
assert hasattr(ext, 'description')
def test_get_a2ui_agent_extension_with_inline_catalog(self):
"""Test get_a2ui_agent_extension with inline catalog support."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension(accepts_inline_custom_catalog=True)
assert ext.params is not None
assert ext.params.get("acceptsInlineCustomCatalog") is True
def test_standard_catalog_id(self):
"""Test standard catalog ID constant."""
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
assert "standard_catalog" in STANDARD_CATALOG_ID.lower() or "a2ui" in STANDARD_CATALOG_ID.lower()
class TestA2UIDataPart:
"""Test A2UI DataPart type."""
def test_a2ui_data_part_creation(self):
"""Test A2UIDataPart creation."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"test": "value"},
metadata={"mimeType": "application/json+a2ui"}
)
assert part.data == {"test": "value"}
assert part.metadata["mimeType"] == "application/json+a2ui"
def test_a2ui_data_part_to_dict(self):
"""Test A2UIDataPart serialization."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"createSurface": {"surfaceId": "main"}},
metadata={"mimeType": "application/json+a2ui"}
)
data = part.to_dict()
assert "data" in data
assert "metadata" in data
@@ -0,0 +1,225 @@
"""
End-to-End Integration Tests for A2UI
Tests the complete A2UI workflow from agent to JSON output.
"""
import json
import os
import pytest
class TestA2UIEndToEnd:
"""End-to-end integration tests."""
def test_complete_surface_workflow(self):
"""Test complete workflow: create surface, add components, generate JSON."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
)
# Create surface
surface = Surface(surface_id="test-surface")
# Add components using fluent API
surface.text(id="title", text="Welcome", usage_hint="h1")
surface.text(id="subtitle", text=PathBinding(path="/subtitle"), usage_hint="h2")
surface.button(
id="action-btn",
child="btn-text",
action_name="do_action",
action_context=[{"key": "id", "value": "123"}],
primary=True
)
surface.text(id="btn-text", text="Click Me")
surface.column(id="root", children=["title", "subtitle", "action-btn"])
# Set data
surface.set_data("subtitle", "Hello World")
# Generate messages
messages = surface.to_messages()
# Verify structure
assert len(messages) == 3 # createSurface, updateComponents, updateDataModel
# Verify createSurface
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "test-surface"
# Verify updateComponents
assert "updateComponents" in messages[1]
components = messages[1]["updateComponents"]["components"]
assert len(components) == 5 # title, subtitle, btn, btn-text, root
# Verify updateDataModel
assert "updateDataModel" in messages[2]
assert messages[2]["updateDataModel"]["value"]["subtitle"] == "Hello World"
# Verify JSON output
json_str = surface.to_json()
parsed = json.loads(json_str)
assert len(parsed) == 3
def test_a2ui_agent_render_workflow(self):
"""Test A2UIAgent rendering workflow."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent, surface_id="agent-surface")
# Render text
messages = a2ui_agent.render_text(
text="This is a test response",
title="Test Title"
)
# Verify structure
assert len(messages) >= 2
assert "createSurface" in messages[0]
# Verify JSON export
json_str = a2ui_agent.to_json()
parsed = json.loads(json_str)
assert isinstance(parsed, list)
def test_template_workflow(self):
"""Test template-based workflow."""
from praisonaiagents.ui.a2ui import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
)
# Chat template
chat = ChatTemplate(surface_id="chat")
chat.add_user_message("Hello!")
chat.add_agent_message("Hi there!")
chat_messages = chat.to_messages()
assert len(chat_messages) >= 2
# List template
list_template = ListTemplate(surface_id="list", title="Items")
list_template.add_item(title="Item 1", description="First")
list_template.add_item(title="Item 2", description="Second")
list_messages = list_template.to_messages()
assert len(list_messages) >= 2
# Form template
form = FormTemplate(surface_id="form", title="Contact")
form.add_text_field(id="name", label="Name")
form.add_email_field(id="email", label="Email")
form.set_submit_action("submit", "Send")
form_messages = form.to_messages()
assert len(form_messages) >= 2
# Dashboard template
dashboard = DashboardTemplate(surface_id="dashboard", title="Dashboard")
dashboard.add_panel(id="stats", title="Stats", content="100 items")
dashboard.add_panel(id="chart", title="Chart", content="[Chart]")
dashboard_messages = dashboard.to_messages()
assert len(dashboard_messages) >= 2
def test_a2a_integration(self):
"""Test A2A protocol integration."""
from praisonaiagents.ui.a2ui import (
Surface,
create_a2ui_part,
is_a2ui_part,
A2UI_MIME_TYPE,
)
# Create surface
surface = Surface(surface_id="a2a-test")
surface.text(id="msg", text="Hello from A2A")
surface.column(id="root", children=["msg"])
# Get messages
messages = surface.to_messages()
# Wrap in A2A DataPart
part = create_a2ui_part({"messages": messages})
# Verify A2UI part
assert is_a2ui_part(part)
assert part.metadata["mimeType"] == A2UI_MIME_TYPE
assert "messages" in part.data
assert len(part.data["messages"]) >= 2
def test_data_binding_workflow(self):
"""Test data binding with path references."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
ChildrenTemplate,
)
surface = Surface(surface_id="data-binding-test")
# Add components with data binding
surface.text(id="title", text=PathBinding(path="/title"), usage_hint="h1")
surface.text(id="item-template", text=PathBinding(path="/name"))
# Add list with template
surface.list(
id="items",
children=ChildrenTemplate(component_id="item-template", path="/items"),
direction="vertical"
)
surface.column(id="root", children=["title", "items"])
# Set data
surface.set_data("title", "My List")
surface.set_data("items", [
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
])
messages = surface.to_messages()
# Verify data model
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "My List"
assert len(data_msg["updateDataModel"]["value"]["items"]) == 3
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIWithLLM:
"""Tests that require LLM API calls."""
def test_agent_chat_and_render(self):
"""Test agent chat followed by UI rendering."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# Chat with agent
response = a2ui_agent.chat("What is 2+2?")
# Render response as UI
messages = a2ui_agent.render_text(response, title="Answer")
assert len(messages) >= 2
assert "createSurface" in messages[0]
@@ -0,0 +1,228 @@
"""
Tests for A2UI Surface Builder
TDD: Write tests first, then implement the Surface class.
"""
class TestSurface:
"""Test Surface builder class."""
def test_surface_creation(self):
"""Test Surface creation with ID and catalog."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
assert surface.surface_id == "main"
assert surface.catalog_id is not None # Should have default
def test_surface_with_custom_catalog(self):
"""Test Surface with custom catalog ID."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(
surface_id="main",
catalog_id="https://custom.catalog/v1"
)
assert surface.catalog_id == "https://custom.catalog/v1"
def test_add_component(self):
"""Test adding a component to the surface."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
surface.add(text)
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_add_multiple_components(self):
"""Test adding multiple components."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.add(ButtonComponent(id="btn", child="btn-text"))
assert len(surface.components) == 2
def test_set_data(self):
"""Test setting data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("title", "Hello World")
surface.set_data("count", 42)
assert surface.data["title"] == "Hello World"
assert surface.data["count"] == 42
def test_set_nested_data(self):
"""Test setting nested data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("user", {"name": "John", "age": 30})
assert surface.data["user"]["name"] == "John"
def test_to_messages_creates_surface(self):
"""Test to_messages generates CreateSurface message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
assert len(messages) >= 1
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "main"
def test_to_messages_includes_components(self):
"""Test to_messages includes UpdateComponents message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
# Should have createSurface and updateComponents
assert len(messages) >= 2
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
assert len(update_msg["updateComponents"]["components"]) == 1
def test_to_messages_includes_data_model(self):
"""Test to_messages includes UpdateDataModel message when data is set."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.set_data("title", "Hello World")
messages = surface.to_messages()
# Should have createSurface, updateComponents, and updateDataModel
assert len(messages) == 3
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "Hello World"
def test_to_json(self):
"""Test to_json returns JSON string."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
import json
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
json_str = surface.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
assert len(parsed) >= 2
class TestSurfaceChaining:
"""Test Surface method chaining."""
def test_add_returns_self(self):
"""Test add() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
result = surface.add(TextComponent(id="title", text="Hello"))
assert result is surface
def test_set_data_returns_self(self):
"""Test set_data() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
result = surface.set_data("key", "value")
assert result is surface
def test_fluent_api(self):
"""Test fluent API for building surfaces."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = (
Surface(surface_id="main")
.add(TextComponent(id="title", text="Hello"))
.add(ButtonComponent(id="btn", child="btn-text"))
.set_data("title", "Hello World")
)
assert len(surface.components) == 2
assert surface.data["title"] == "Hello World"
class TestSurfaceHelpers:
"""Test Surface helper methods."""
def test_text_helper(self):
"""Test text() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.text(id="title", text="Hello", usage_hint="h1")
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_button_helper(self):
"""Test button() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.button(id="submit", child="submit-text", action_name="submit")
assert len(surface.components) == 1
assert surface.components[0].id == "submit"
def test_card_helper(self):
"""Test card() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.card(id="card-1", child="card-content")
assert len(surface.components) == 1
assert surface.components[0].id == "card-1"
def test_row_helper(self):
"""Test row() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.row(id="row-1", children=["col-1", "col-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "row-1"
def test_column_helper(self):
"""Test column() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.column(id="col-1", children=["item-1", "item-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "col-1"
@@ -0,0 +1,210 @@
"""
Tests for A2UI Templates
TDD: Write tests first, then implement the templates.
"""
class TestChatTemplate:
"""Test ChatTemplate for conversational UIs."""
def test_chat_template_creation(self):
"""Test ChatTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
assert template.surface_id == "chat"
def test_add_user_message(self):
"""Test adding a user message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "user"
assert template.messages[0]["content"] == "Hello!"
def test_add_agent_message(self):
"""Test adding an agent message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_agent_message("Hi there!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "agent"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
template.add_agent_message("Hi there!")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
class TestListTemplate:
"""Test ListTemplate for displaying lists of items."""
def test_list_template_creation(self):
"""Test ListTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(
surface_id="list",
title="My Items"
)
assert template.surface_id == "list"
assert template.title == "My Items"
def test_add_item(self):
"""Test adding an item to the list."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First item")
assert len(template.items) == 1
assert template.items[0]["title"] == "Item 1"
def test_add_item_with_image(self):
"""Test adding an item with an image."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(
title="Item 1",
description="First item",
image_url="https://example.com/image.jpg"
)
assert template.items[0]["image_url"] == "https://example.com/image.jpg"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First")
template.add_item(title="Item 2", description="Second")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestFormTemplate:
"""Test FormTemplate for data collection forms."""
def test_form_template_creation(self):
"""Test FormTemplate creation."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(
surface_id="form",
title="Contact Form"
)
assert template.surface_id == "form"
assert template.title == "Contact Form"
def test_add_text_field(self):
"""Test adding a text field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
assert len(template.fields) == 1
assert template.fields[0]["id"] == "name"
assert template.fields[0]["type"] == "text"
def test_add_number_field(self):
"""Test adding a number field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_number_field(id="age", label="Age")
assert template.fields[0]["type"] == "number"
def test_add_email_field(self):
"""Test adding an email field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_email_field(id="email", label="Email")
assert template.fields[0]["type"] == "email"
def test_set_submit_action(self):
"""Test setting submit action."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.set_submit_action("submit_form", "Send")
assert template.submit_action == "submit_form"
assert template.submit_label == "Send"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
template.add_email_field(id="email", label="Email")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestDashboardTemplate:
"""Test DashboardTemplate for multi-panel dashboards."""
def test_dashboard_template_creation(self):
"""Test DashboardTemplate creation."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(
surface_id="dashboard",
title="My Dashboard"
)
assert template.surface_id == "dashboard"
assert template.title == "My Dashboard"
def test_add_panel(self):
"""Test adding a panel."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Some content")
assert len(template.panels) == 1
assert template.panels[0]["id"] == "panel-1"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Content 1")
template.add_panel(id="panel-2", title="Panel 2", content="Content 2")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
@@ -0,0 +1,318 @@
"""
Tests for A2UI Types Module
TDD: Write tests first, then implement the types.
"""
class TestA2UIMessageTypes:
"""Test A2UI message types: CreateSurface, UpdateComponents, UpdateDataModel, DeleteSurface."""
def test_create_surface_message(self):
"""Test CreateSurface message creation."""
from praisonaiagents.ui.a2ui.types import CreateSurfaceMessage
msg = CreateSurfaceMessage(
surface_id="main",
catalog_id="https://a2ui.dev/standard"
)
assert msg.surface_id == "main"
assert msg.catalog_id == "https://a2ui.dev/standard"
# Test serialization
data = msg.to_dict()
assert "createSurface" in data
assert data["createSurface"]["surfaceId"] == "main"
assert data["createSurface"]["catalogId"] == "https://a2ui.dev/standard"
def test_update_components_message(self):
"""Test UpdateComponents message creation."""
from praisonaiagents.ui.a2ui.types import UpdateComponentsMessage, TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
msg = UpdateComponentsMessage(
surface_id="main",
components=[text]
)
assert msg.surface_id == "main"
assert len(msg.components) == 1
# Test serialization
data = msg.to_dict()
assert "updateComponents" in data
assert data["updateComponents"]["surfaceId"] == "main"
assert len(data["updateComponents"]["components"]) == 1
def test_update_data_model_message(self):
"""Test UpdateDataModel message creation."""
from praisonaiagents.ui.a2ui.types import UpdateDataModelMessage
msg = UpdateDataModelMessage(
surface_id="main",
path="/",
op="replace",
value={"title": "Hello", "items": []}
)
assert msg.surface_id == "main"
assert msg.path == "/"
assert msg.op == "replace"
assert msg.value == {"title": "Hello", "items": []}
# Test serialization
data = msg.to_dict()
assert "updateDataModel" in data
assert data["updateDataModel"]["surfaceId"] == "main"
def test_delete_surface_message(self):
"""Test DeleteSurface message creation."""
from praisonaiagents.ui.a2ui.types import DeleteSurfaceMessage
msg = DeleteSurfaceMessage(surface_id="main")
assert msg.surface_id == "main"
# Test serialization
data = msg.to_dict()
assert "deleteSurface" in data
assert data["deleteSurface"]["surfaceId"] == "main"
class TestA2UIComponentTypes:
"""Test A2UI component types."""
def test_text_component(self):
"""Test Text component creation."""
from praisonaiagents.ui.a2ui.types import TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
assert text.id == "title"
assert text.text == "Hello World"
assert text.usage_hint == "h1"
# Test serialization
data = text.to_dict()
assert data["id"] == "title"
assert data["component"] == "Text"
assert data["text"] == "Hello World"
assert data["usageHint"] == "h1"
def test_text_component_with_path(self):
"""Test Text component with data binding path."""
from praisonaiagents.ui.a2ui.types import TextComponent, PathBinding
text = TextComponent(id="title", text=PathBinding(path="/title"))
data = text.to_dict()
assert data["text"] == {"path": "/title"}
def test_image_component(self):
"""Test Image component creation."""
from praisonaiagents.ui.a2ui.types import ImageComponent
img = ImageComponent(
id="hero",
url="https://example.com/image.jpg",
fit="cover",
usage_hint="header"
)
assert img.id == "hero"
assert img.url == "https://example.com/image.jpg"
assert img.fit == "cover"
data = img.to_dict()
assert data["component"] == "Image"
assert data["url"] == "https://example.com/image.jpg"
def test_button_component(self):
"""Test Button component creation."""
from praisonaiagents.ui.a2ui.types import ButtonComponent, Action, ActionContext
action = Action(
name="submit",
context=[
ActionContext(key="value", value="test")
]
)
btn = ButtonComponent(
id="submit-btn",
child="submit-text",
primary=True,
action=action
)
assert btn.id == "submit-btn"
assert btn.child == "submit-text"
assert btn.primary is True
data = btn.to_dict()
assert data["component"] == "Button"
assert data["action"]["name"] == "submit"
def test_card_component(self):
"""Test Card component creation."""
from praisonaiagents.ui.a2ui.types import CardComponent
card = CardComponent(id="card-1", child="card-content")
assert card.id == "card-1"
assert card.child == "card-content"
data = card.to_dict()
assert data["component"] == "Card"
assert data["child"] == "card-content"
def test_row_component(self):
"""Test Row component creation."""
from praisonaiagents.ui.a2ui.types import RowComponent
row = RowComponent(id="row-1", children=["col-1", "col-2"])
assert row.id == "row-1"
assert row.children == ["col-1", "col-2"]
data = row.to_dict()
assert data["component"] == "Row"
assert data["children"] == ["col-1", "col-2"]
def test_column_component(self):
"""Test Column component creation."""
from praisonaiagents.ui.a2ui.types import ColumnComponent
col = ColumnComponent(id="col-1", children=["item-1", "item-2"])
assert col.id == "col-1"
assert col.children == ["item-1", "item-2"]
data = col.to_dict()
assert data["component"] == "Column"
def test_list_component(self):
"""Test List component creation."""
from praisonaiagents.ui.a2ui.types import ListComponent, ChildrenTemplate
template = ChildrenTemplate(component_id="item-template", path="/items")
lst = ListComponent(id="item-list", direction="vertical", children=template)
assert lst.id == "item-list"
assert lst.direction == "vertical"
data = lst.to_dict()
assert data["component"] == "List"
assert data["children"]["componentId"] == "item-template"
assert data["children"]["path"] == "/items"
def test_text_field_component(self):
"""Test TextField component creation."""
from praisonaiagents.ui.a2ui.types import TextFieldComponent, PathBinding
field = TextFieldComponent(
id="name-field",
label="Name",
text=PathBinding(path="/name"),
field_type="text"
)
assert field.id == "name-field"
assert field.label == "Name"
data = field.to_dict()
assert data["component"] == "TextField"
assert data["label"] == "Name"
assert data["text"] == {"path": "/name"}
def test_checkbox_component(self):
"""Test CheckBox component creation."""
from praisonaiagents.ui.a2ui.types import CheckBoxComponent, PathBinding
cb = CheckBoxComponent(
id="agree-cb",
label="I agree",
checked=PathBinding(path="/agreed")
)
data = cb.to_dict()
assert data["component"] == "CheckBox"
assert data["label"] == "I agree"
def test_slider_component(self):
"""Test Slider component creation."""
from praisonaiagents.ui.a2ui.types import SliderComponent
slider = SliderComponent(
id="volume",
min_value=0,
max_value=100,
value=50
)
data = slider.to_dict()
assert data["component"] == "Slider"
assert data["min"] == 0
assert data["max"] == 100
class TestDataBindingTypes:
"""Test data binding types."""
def test_path_binding(self):
"""Test PathBinding for data model references."""
from praisonaiagents.ui.a2ui.types import PathBinding
path = PathBinding(path="/user/name")
assert path.path == "/user/name"
assert path.to_dict() == {"path": "/user/name"}
def test_string_or_path_literal(self):
"""Test StringOrPath with literal string."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path
result = resolve_string_or_path("Hello")
assert result == "Hello"
def test_string_or_path_binding(self):
"""Test StringOrPath with path binding."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path, PathBinding
result = resolve_string_or_path(PathBinding(path="/title"))
assert result == {"path": "/title"}
class TestActionTypes:
"""Test action types for button events."""
def test_action_creation(self):
"""Test Action creation."""
from praisonaiagents.ui.a2ui.types import Action, ActionContext
action = Action(
name="book_restaurant",
context=[
ActionContext(key="restaurantName", value="The Fancy Place"),
ActionContext(key="address", value="123 Main St")
]
)
assert action.name == "book_restaurant"
assert len(action.context) == 2
data = action.to_dict()
assert data["name"] == "book_restaurant"
assert len(data["context"]) == 2
assert data["context"][0]["key"] == "restaurantName"
def test_action_context_with_path(self):
"""Test ActionContext with path binding."""
from praisonaiagents.ui.a2ui.types import ActionContext, PathBinding
ctx = ActionContext(key="name", value=PathBinding(path="/restaurant/name"))
data = ctx.to_dict()
assert data["key"] == "name"
assert data["value"] == {"path": "/restaurant/name"}
@@ -98,38 +98,6 @@ class TestToolCallStreaming:
assert event.content == "Search results..."
class TestStreamEventBridge:
"""Test PraisonAI stream_emitter → AG-UI event conversion."""
def test_delta_text_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 2
assert agui_events[0].type == "TEXT_MESSAGE_START"
assert agui_events[1].type == "TEXT_MESSAGE_CONTENT"
assert agui_events[1].delta == "Hello"
def test_tool_call_start_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={"id": "tc-1", "name": "search", "arguments": {"q": "test"}},
)
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 3
assert agui_events[0].type == "TOOL_CALL_START"
assert agui_events[0].tool_call_name == "search"
class TestRunLifecycleEvents:
"""Test run lifecycle events."""
@@ -1,328 +0,0 @@
"""Integration tests for the ALLOWED_TOOLS filter module."""
import os
import pytest
from unittest.mock import patch, MagicMock
from praisonai.cli.features.agents import MultiAgentHandler
class TestAllowedToolsFilterRegistryIntegration:
"""Integration tests for ALLOWED_TOOLS filter with the tool registry."""
def test_registry_allowed_filter_unset(self):
"""Test registry filter behavior when ALLOWED_TOOLS is unset."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {}, clear=True):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return all tools when unset
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_with_whitelist(self):
"""Test registry filter with ALLOWED_TOOLS whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search_web,send_email,unknown_tool",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email', 'extract_pdf']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should only include whitelisted tools that are available
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_empty_result(self):
"""Test registry filter when no tools match whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "nonexistent_tool1,nonexistent_tool2",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return empty list when no matches
assert filtered_tools == []
def test_registry_allowed_filter_import_error_fallback(self):
"""Test registry filter fallback when import fails."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock import error for the filter
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=ImportError("Filter not available")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string should cause ValueError
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
with pytest.raises(ValueError, match="cannot be empty"):
registry.list_tools_with_allowed_filter()
def test_registry_allowed_filter_general_error_fallback(self):
"""Test registry filter fallback on general errors."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock a generic error during filtering
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=RuntimeError("Some error")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools on general errors
assert set(filtered_tools) == {'search_web', 'send_email'}
class TestAllowedToolsFilterGlobalRegistryPath:
"""Test ALLOWED_TOOLS filter through the global registry function."""
def test_global_list_tools_with_allowed_filter(self):
"""Test the global list_tools_with_allowed_filter function."""
from praisonaiagents.tools import list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search_web,send_email"}):
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web', 'send_email']) as mock_filter:
result = list_tools_with_allowed_filter()
# Should call the registry method
mock_filter.assert_called_once_with(None)
assert result == ['search_web', 'send_email']
def test_global_backward_compatibility_alias(self):
"""Test the backward compatibility alias for hermes filter."""
from praisonaiagents.tools import list_tools_with_hermes_filter, list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web']) as mock_filter:
# Both should call the same underlying function
result1 = list_tools_with_allowed_filter()
result2 = list_tools_with_hermes_filter()
assert result1 == result2 == ['search_web']
assert mock_filter.call_count == 2
class TestAllowedToolsFilterCLIIntegration:
"""Integration tests for ALLOWED_TOOLS filter with CLI agent handler."""
def test_cli_agents_load_tools_no_filter(self):
"""Test CLI agent tool loading without ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {}, clear=True):
# Mock the tools import to avoid actual import dependencies
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all requested tools when no filter
assert len(tools) == 2
def test_cli_agents_load_tools_with_filter(self):
"""Test CLI agent tool loading with ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "internet_search"}):
# Mock the tools and filter
mock_internet_search = MagicMock()
mock_read_file = MagicMock()
with patch('praisonai.cli.features.agents.internet_search', mock_internet_search), \
patch('praisonai.cli.features.agents.read_file', mock_read_file):
tools = handler._load_tools(["internet_search", "read_file"])
# Should only load tools allowed by filter
assert len(tools) == 1
assert mock_internet_search in tools
assert mock_read_file not in tools
def test_cli_agents_load_tools_filter_error_handling(self):
"""Test CLI tool loading with ALLOWED_TOOLS filter error handling."""
handler = MultiAgentHandler(verbose=False)
requested = ["internet_search", "read_file"]
with patch('os.environ.get', return_value=None):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
baseline_tools = handler._load_tools(requested)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError):
handler._load_tools(requested)
def test_cli_agents_load_tools_import_error_fallback(self):
"""Test CLI tool loading fallback when ALLOWED_TOOLS filter import fails."""
handler = MultiAgentHandler(verbose=False)
# Mock ImportError for the filter module
with patch('praisonai.cli.features.agents.filter_tools_with_allowed_tools', side_effect=ImportError("Filter module not available")):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all tools on import error
assert len(tools) == 2
def test_cli_agents_load_tools_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated in CLI."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string causes ValueError
with pytest.raises(ValueError, match="cannot be empty"):
handler._load_tools(["internet_search"])
class TestAllowedToolsFilterSpecSemantics:
"""Test ALLOWED_TOOLS filter specification semantics."""
def test_spec_semantics_unset_all_tools_visible(self):
"""Test spec: unset ALLOWED_TOOLS means all tools visible with warning."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
# Should warn about unset whitelist
mock_warn.assert_called_once()
assert "Tool whitelist is unset" in mock_warn.call_args[0][0]
def test_spec_semantics_empty_string_error(self):
"""Test spec: empty ALLOWED_TOOLS string is an error."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_spec_semantics_whitelist_only_specified_visible(self):
"""Test spec: with ALLOWED_TOOLS values, only whitelisted tools visible."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf", "analyze_code"}
filtered = filter_instance.filter_tools(available_tools)
# Should only return whitelisted tools
assert filtered == {"search", "send_message"}
def test_spec_semantics_unknown_tools_dev_warn_strip(self):
"""Test spec: unknown tools in dev mode are warned and stripped."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "false"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return only known tools
assert filtered == {"search"}
# Should warn about unknown tools
mock_warn.assert_called()
assert "unknown_tool" in str(mock_warn.call_args[0][2])
def test_spec_semantics_unknown_tools_ci_strict_fail(self):
"""Test spec: unknown tools in CI mode cause strict failure."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "true"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
def test_spec_semantics_order_independence(self):
"""Test spec: whitelist order doesn't matter."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
available_tools = {"search", "send_message", "extract_pdf"}
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter1 = AllowedToolsFilter()
result1 = filter1.filter_tools(available_tools)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "send_message,search"}):
filter2 = AllowedToolsFilter()
result2 = filter2.filter_tools(available_tools)
# Results should be the same regardless of order
assert result1 == result2 == {"search", "send_message"}
def test_spec_semantics_duplicate_isolation(self):
"""Test spec: duplicate tool names in whitelist are handled properly."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,search,send_message,search"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Duplicates should be de-duplicated
assert filtered == {"search", "send_message"}
# Whitelist should be a set (no duplicates)
whitelist = filter_instance.get_whitelist()
assert whitelist == {"search", "send_message"}
def test_spec_backward_compatibility_hermes_precedence(self):
"""Test spec: ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "extract_pdf"
}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Should use ALLOWED_TOOLS, not HERMES_ONLY_TOOLS
assert filtered == {"search", "send_message"}
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
@@ -6,7 +6,6 @@ Databases tested (all via local Docker):
2. PostgreSQL — ConversationStore + ManagedAgent roundtrip
3. MySQL — ConversationStore + ManagedAgent roundtrip
4. Redis — StateStore + metadata persistence
4a. Valkey — StateStore + metadata persistence
5. MongoDB — StateStore + metadata persistence
6. ClickHouse — Raw connectivity + data write/read
7. JSON file — DefaultSessionStore + ManagedAgent roundtrip
@@ -28,6 +27,7 @@ from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -562,206 +562,6 @@ class TestRedisStateStore:
r.close()
# ===========================================================================
# 4a. Valkey — StateStore operations + metadata persistence
# ===========================================================================
class TestValkeyStateStore:
"""Valkey StateStore: real operations against a live Valkey instance."""
@pytest.fixture(autouse=True)
def check_valkey(self):
"""Skip if Valkey not available."""
try:
from praisonai.persistence._valkey_client import create_valkey_client
client = create_valkey_client(host="localhost", port=6379)
client.ping()
client.close()
except Exception:
pytest.skip("Valkey not available on localhost:6379")
def test_valkey_state_operations(self):
"""StateStore get/set/delete/exists/hash with real Valkey."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"test_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
# Basic set/get
store.set("agent_id", "agent_valkey_001")
assert store.get("agent_id") == "agent_valkey_001"
# JSON roundtrip
state = {
"agent_id": "agent_valkey_001",
"agent_version": 3,
"total_input_tokens": 500,
"total_output_tokens": 200,
"compute_instance_id": "docker_valkey_test",
"session_history": [{"id": "s1", "status": "idle"}],
}
store.set_json("managed_state", state)
recovered = store.get_json("managed_state")
assert recovered["agent_id"] == "agent_valkey_001"
assert recovered["total_input_tokens"] == 500
assert recovered["compute_instance_id"] == "docker_valkey_test"
assert len(recovered["session_history"]) == 1
# Hash operations
store.hset("agent_meta", "version", "5")
store.hset("agent_meta", "env_id", "env_abc")
assert str(store.hget("agent_meta", "version")) == "5"
all_meta = store.hgetall("agent_meta")
assert str(all_meta["env_id"]) == "env_abc"
# Exists and delete
assert store.exists("agent_id")
store.delete("agent_id")
assert not store.exists("agent_id")
finally:
for k in store.keys():
store.delete(k)
store.close()
def test_valkey_managed_state_persist_restore(self):
"""Simulate managed agent state persist/restore across store instances."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"mgd_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
session_id = f"valkey_session_{uuid.uuid4().hex[:8]}"
state = {
"agent_id": "agent_valkey_mgd",
"agent_version": 2,
"environment_id": "env_valkey_001",
"total_input_tokens": 1000,
"total_output_tokens": 500,
"compute_instance_id": "modal_valkey_xyz",
"session_history": [
{"id": session_id, "status": "idle", "title": "Valkey test"},
],
}
store.set_json(f"managed:{session_id}", state)
# Simulate restart — new store instance, same prefix
store2 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
recovered = store2.get_json(f"managed:{session_id}")
assert recovered is not None
assert recovered["agent_id"] == "agent_valkey_mgd"
assert recovered["total_input_tokens"] == 1000
assert recovered["compute_instance_id"] == "modal_valkey_xyz"
assert len(recovered["session_history"]) == 1
store.close()
store2.close()
finally:
store3 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
for k in store3.keys():
store3.delete(k)
store3.close()
# ===========================================================================
# 4b. Valkey Vector Search — ValkeyVectorKnowledgeStore against a live instance
# ===========================================================================
class TestValkeyVectorSearch:
"""Valkey vector search: real operations against a live ValkeySearch instance."""
@pytest.fixture(autouse=True)
def check_valkey_search(self):
"""Skip if Valkey is unreachable or ValkeySearch (FT.*) is not loaded."""
try:
from glide_sync import ft
except ImportError:
pytest.skip("valkey-glide-sync not installed")
try:
from praisonai.persistence._valkey_client import create_valkey_client
except ImportError as e:
pytest.skip(f"praisonai not importable: {e}")
client = None
try:
client = create_valkey_client(host="localhost", port=6379)
client.ping()
except Exception as e:
if client is not None:
client.close()
pytest.skip(f"Valkey not available on localhost:6379: {e}")
try:
# Probe FT.* support; raises if module not loaded
ft.list(client)
except Exception as e:
pytest.skip(f"ValkeySearch (FT.*) module not loaded on localhost:6379: {e}")
finally:
if client is not None:
client.close()
def test_valkey_vector_search_roundtrip(self):
"""Full create → insert → search → delete_collection roundtrip."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
from praisonai.persistence.knowledge.base import KnowledgeDocument
prefix = f"praisontest_{uuid.uuid4().hex[:6]}_"
collection = f"vec_{uuid.uuid4().hex[:6]}"
dimension = 4
store = ValkeyVectorKnowledgeStore(host="localhost", port=6379, prefix=prefix)
try:
# Create index
store.create_collection(collection, dimension=dimension, distance="cosine")
assert store.collection_exists(collection)
# Insert documents with simple, distinguishable embeddings
docs = [
KnowledgeDocument(
id="doc_a",
content="alpha document",
embedding=[1.0, 0.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_b",
content="bravo document",
embedding=[0.0, 1.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_c",
content="charlie document",
embedding=[0.0, 0.0, 1.0, 0.0],
),
]
inserted = store.insert(collection, docs)
assert set(inserted) == {"doc_a", "doc_b", "doc_c"}
# Vector search: query closest to doc_a
results = store.search(collection, query_embedding=[1.0, 0.0, 0.0, 0.0], limit=2)
assert len(results) >= 1, "Expected at least one search result"
assert results[0].id == "doc_a", f"Expected doc_a as nearest neighbor, got {results[0].id}"
assert results[0].content == "alpha document"
# Verify the bare ID is returned (no prefix leakage)
for r in results:
assert ":" not in r.id or r.id in {"doc_a", "doc_b", "doc_c"}
# get() roundtrip
got = store.get(collection, ["doc_b"])
assert len(got) == 1
assert got[0].id == "doc_b"
assert got[0].content == "bravo document"
# Delete one doc
deleted = store.delete(collection, ids=["doc_c"])
assert deleted == 1
finally:
try:
store.delete_collection(collection)
finally:
store.close()
# ===========================================================================
# 5. MongoDB — StateStore operations + metadata persistence
# ===========================================================================
@@ -873,6 +673,7 @@ class TestClickHouseConnectivity:
def test_clickhouse_write_read(self):
"""Write and read data from ClickHouse."""
import clickhouse_connect
table = f"praison_test_{uuid.uuid4().hex[:8]}"
c = clickhouse_connect.get_client(
host="localhost", port=8123, username="clickhouse", password="clickhouse",
@@ -1,84 +0,0 @@
"""Unit tests for unified achat dispatch (Issue #1703)."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from praisonaiagents import Agent
def _fake_completion(content: str = "Mock async reply"):
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=content, reasoning_content=None))]
)
@pytest.mark.asyncio
async def test_unified_achat_returns_after_single_dispatch():
"""achat must return after one unified dispatch, not loop forever."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
agent.self_reflect = False
mock_unified = AsyncMock(return_value=_fake_completion())
with patch.object(agent, "_execute_unified_achat_completion", mock_unified), patch.object(
agent, "_apply_guardrail_with_retry", return_value="Mock async reply"
), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="Mock async reply"
), patch.object(
agent, "_execute_callback_and_display"
), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(
agent, "_persist_message"
), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
result = await asyncio.wait_for(agent.achat("hi", stream=False), timeout=5)
assert result == "Mock async reply"
mock_unified.assert_called_once()
@pytest.mark.asyncio
async def test_unified_achat_completion_uses_execute_tool_async():
"""Async unified dispatch must wire execute_tool_async, not sync execute_tool."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
mock_dispatcher = MagicMock()
mock_dispatcher.achat_completion = AsyncMock(return_value=_fake_completion())
agent._unified_dispatcher = mock_dispatcher
with patch.object(agent, "_apply_guardrail_with_retry", return_value="ok"), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="ok"
), patch.object(agent, "_execute_callback_and_display"), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(agent, "_persist_message"), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
await agent._achat_impl(
prompt="hi",
temperature=1.0,
tools=None,
output_json=None,
output_pydantic=None,
reasoning_steps=False,
stream=False,
task_name=None,
task_description=None,
task_id=None,
config=None,
force_retrieval=False,
skip_retrieval=True,
attachments=None,
_trace_emitter=MagicMock(),
)
call_kwargs = mock_dispatcher.achat_completion.call_args[1]
tool_fn = call_kwargs.get("execute_tool_fn")
assert tool_fn is not None
assert getattr(tool_fn, "__name__", "") == "execute_tool_async"
@@ -1,128 +0,0 @@
"""Test for agent server readiness timeout warning functionality."""
import time
import threading
import logging
import pytest
from unittest.mock import patch, MagicMock
from praisonaiagents.agents.agents import _AgentServerRegistry
def test_server_readiness_timeout_emits_warning(caplog):
"""A server that does not start in time must produce a WARNING log entry."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app that won't signal readiness
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8000: mock_app}):
with patch.dict(registry._ready_events, {8000: threading.Event()}):
with patch.dict(registry._started, {8000: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
with caplog.at_level(logging.WARNING):
# Use a very short timeout to ensure the test doesn't hang
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.1"}):
result = registry.start_server_if_needed(8000, host="127.0.0.1")
# The method should still return True but log a warning
assert result is True
assert "8000" in caplog.text
assert "did not become ready" in caplog.text.lower()
def test_server_readiness_timeout_configurable_via_env():
"""The timeout should be configurable via PRAISONAI_SERVER_READY_TIMEOUT."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8001: mock_app}):
with patch.dict(registry._ready_events, {8001: threading.Event()}):
with patch.dict(registry._started, {8001: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with custom timeout
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.05"}):
start_time = time.time()
result = registry.start_server_if_needed(8001, host="127.0.0.1")
duration = time.time() - start_time
# Should respect the custom timeout and complete quickly
assert result is True
assert duration < 1.0 # Should be much less than default 5s
def test_server_readiness_success_no_warning(caplog):
"""A server that starts in time should not produce any warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app and signal readiness immediately
with patch.dict(registry._apps, {8002: mock_app}):
with patch.dict(registry._ready_events, {8002: ready_event}):
with patch.dict(registry._started, {8002: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Set the event immediately to simulate quick startup
ready_event.set()
with caplog.at_level(logging.WARNING):
result = registry.start_server_if_needed(8002, host="127.0.0.1")
# Should succeed without warnings
assert result is True
assert "did not become ready" not in caplog.text.lower()
def test_default_timeout_value(monkeypatch):
"""Test that the default timeout is 5.0 seconds when env var is not set."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8003: mock_app}):
with patch.dict(registry._ready_events, {8003: ready_event}):
with patch.dict(registry._started, {8003: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Remove only the specific env var so other env vars (PATH etc.) remain intact
monkeypatch.delenv("PRAISONAI_SERVER_READY_TIMEOUT", raising=False)
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8003, host="127.0.0.1")
# Should have called wait with default timeout of 5.0
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
def test_invalid_timeout_env_var_fallback(caplog):
"""Test that invalid PRAISONAI_SERVER_READY_TIMEOUT falls back to default with warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8004: mock_app}):
with patch.dict(registry._ready_events, {8004: ready_event}):
with patch.dict(registry._started, {8004: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with invalid timeout value
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "invalid"}):
with caplog.at_level(logging.WARNING):
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8004, host="127.0.0.1")
# Should have fallen back to 5.0 and logged warning
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
assert "Invalid PRAISONAI_SERVER_READY_TIMEOUT" in caplog.text
@@ -48,12 +48,6 @@ class TestResolveMemory:
result = resolve_memory("redis")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.REDIS
def test_string_valkey_backend(self):
"""'valkey' string returns MemoryConfig with VALKEY backend."""
result = resolve_memory("valkey")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.VALKEY
def test_string_custom_backend(self):
"""Custom string backend is preserved."""
@@ -1 +0,0 @@
# Sandbox tests package
@@ -1,199 +0,0 @@
"""
Unit tests for SandboxManager.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from praisonaiagents.sandbox.manager import SandboxManager
from praisonaiagents.sandbox.config import SandboxConfig
from praisonaiagents.sandbox.protocols import SandboxResult, SandboxStatus
class TestSandboxManager:
"""Test SandboxManager functionality."""
def test_init_default_config(self):
"""Test initialization with default config."""
manager = SandboxManager()
assert manager.config is not None
assert manager.config.sandbox_type == "subprocess"
def test_init_with_config(self):
"""Test initialization with custom config."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
assert manager.config == config
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_context_manager(self, mock_create_sandbox):
"""Test SandboxManager as async context manager."""
mock_sandbox = AsyncMock()
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
async with manager as sandbox:
assert sandbox == mock_sandbox
mock_create_sandbox.assert_called_once()
# Cleanup should be called
mock_sandbox.stop.assert_called_once()
mock_sandbox.cleanup.assert_called_once()
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_context_manager_cleanup_error(self, mock_create_sandbox):
"""Test context manager cleanup with error."""
mock_sandbox = AsyncMock()
mock_sandbox.stop.side_effect = Exception("Stop failed")
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
# Should not raise exception, just log warning
async with manager as sandbox:
assert sandbox == mock_sandbox
mock_sandbox.stop.assert_called_once()
mock_sandbox.cleanup.assert_called_once()
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_run_code_convenience_method(self, mock_create_sandbox):
"""Test run_code convenience method."""
mock_sandbox = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
mock_sandbox.execute.return_value = mock_result
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
result = await manager.run_code("print('hello')", language="python")
assert result == mock_result
mock_sandbox.execute.assert_called_once_with("print('hello')", language="python")
async def test_create_sandbox_subprocess(self):
"""Test creating subprocess sandbox."""
config = SandboxConfig.subprocess()
manager = SandboxManager(config)
with patch('praisonai.sandbox.subprocess.SubprocessSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_docker(self):
"""Test creating Docker sandbox."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_e2b(self):
"""Test creating E2B sandbox."""
config = SandboxConfig.e2b()
manager = SandboxManager(config)
with patch('praisonai.sandbox.e2b.E2BSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_unknown_type(self):
"""Test creating sandbox with unknown type."""
config = SandboxConfig(sandbox_type="unknown")
manager = SandboxManager(config)
with pytest.raises(ValueError, match="Unknown sandbox type"):
await manager._create_sandbox()
async def test_create_sandbox_unavailable(self):
"""Test creating sandbox when not available."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
mock_instance = Mock()
mock_instance.is_available = False
mock_class.return_value = mock_instance
with pytest.raises(RuntimeError, match="not available"):
await manager._create_sandbox()
async def test_create_sandbox_import_error(self):
"""Test creating sandbox with import error."""
config = SandboxConfig.e2b()
manager = SandboxManager(config)
with patch('importlib.import_module', side_effect=ImportError("Module not found")):
with pytest.raises(ImportError, match="Module not found"):
await manager._create_sandbox()
def test_get_available_types(self):
"""Test getting available sandbox types."""
manager = SandboxManager()
with patch('praisonaiagents.sandbox.manager.SandboxManager._check_availability') as mock_check:
mock_check.side_effect = lambda t: t in ["subprocess", "docker"]
available = manager.get_available_types()
assert "subprocess" in available
assert available["subprocess"] is True
assert "docker" in available
assert available["docker"] is True
assert "e2b" in available
assert available["e2b"] is False
def test_check_availability_subprocess(self):
"""Test checking subprocess availability."""
manager = SandboxManager()
# Subprocess should always be available
available = manager._check_availability("subprocess")
assert available is True
def test_check_availability_docker(self):
"""Test checking Docker availability."""
manager = SandboxManager()
with patch('importlib.import_module') as mock_import:
with patch.object(mock_import.return_value, 'DockerSandbox') as mock_class:
mock_instance = Mock()
mock_instance.is_available = True
mock_class.return_value = mock_instance
available = manager._check_availability("docker")
assert available is True
def test_check_availability_not_available(self):
"""Test checking availability when not available."""
manager = SandboxManager()
with patch('importlib.import_module', side_effect=ImportError()):
available = manager._check_availability("docker")
assert available is False
def test_check_availability_unknown(self):
"""Test checking availability for unknown type."""
manager = SandboxManager()
available = manager._check_availability("unknown")
assert available is False
@@ -1,177 +0,0 @@
"""
Unit tests for SandboxMixin functionality.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from praisonaiagents.agent.sandbox_mixin import SandboxMixin
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
class MockAgent(SandboxMixin):
"""Mock agent class for testing SandboxMixin."""
def __init__(self, sandbox=None, verbose=False):
super().__init__(sandbox=sandbox)
self.verbose = verbose
class TestSandboxMixin:
"""Test SandboxMixin functionality."""
def test_init_no_sandbox(self):
"""Test initialization without sandbox."""
agent = MockAgent()
assert not agent.has_sandbox
assert agent.sandbox_config is None
def test_init_with_boolean_true(self):
"""Test initialization with sandbox=True."""
agent = MockAgent(sandbox=True)
assert agent.has_sandbox
assert agent.sandbox_config is not None
assert agent.sandbox_config.sandbox_type == "subprocess"
def test_init_with_config(self):
"""Test initialization with explicit config."""
config = SandboxConfig.docker("python:3.11")
agent = MockAgent(sandbox=config)
assert agent.has_sandbox
assert agent.sandbox_config == config
def test_get_sandbox_manager_no_config(self):
"""Test get_sandbox_manager without config."""
agent = MockAgent()
manager = agent.get_sandbox_manager()
assert manager is None
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_sandbox_manager_with_config(self, mock_manager_class):
"""Test get_sandbox_manager with config."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
manager = agent.get_sandbox_manager()
mock_manager_class.assert_called_once_with(config)
assert agent._sandbox_manager is not None
def test_execute_code_without_sandbox_raises(self):
"""Test that execute_code raises without sandbox."""
agent = MockAgent()
with pytest.raises(RuntimeError, match="No sandbox configured"):
agent.execute_code_sync("print('hello')")
@patch('praisonaiagents.agent.sandbox_mixin.check_code_safety')
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
async def test_execute_code_with_warnings(self, mock_manager_class, mock_check_safety):
"""Test execute_code with security warnings."""
# Setup
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config, verbose=True)
mock_warnings = ["Potential security issue"]
mock_check_safety.return_value = mock_warnings
mock_manager = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
mock_manager.run_code.return_value = mock_result
mock_manager_class.return_value = mock_manager
# Execute
result = await agent.execute_code("print('test')", check_security=True)
# Verify
mock_check_safety.assert_called_once_with("print('test')", "python")
mock_manager.run_code.assert_called_once()
assert result == mock_result
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
async def test_run_shell_command(self, mock_manager_class):
"""Test run_shell_command functionality."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
# Mock manager as async context manager
mock_manager = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="result")
mock_manager.__aenter__.return_value = mock_manager
mock_manager.__aexit__.return_value = None
mock_manager.run_command.return_value = mock_result
mock_manager_class.return_value = mock_manager
result = await agent.run_shell_command("echo hello")
mock_manager.run_command.assert_called_once_with("echo hello")
assert result == mock_result
def test_get_sandbox_status_no_config(self):
"""Test get_sandbox_status without config."""
agent = MockAgent()
status = agent.get_sandbox_status()
assert status == {"configured": False}
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_sandbox_status_with_config(self, mock_manager_class):
"""Test get_sandbox_status with config."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
mock_manager = Mock()
mock_manager.get_available_types.return_value = {"subprocess": True}
mock_manager_class.return_value = mock_manager
status = agent.get_sandbox_status()
assert status["configured"] is True
assert status["config"] is not None
assert status["available_types"] == {"subprocess": True}
assert status["current_type"] == "subprocess"
def test_get_code_execution_tools_no_sandbox(self):
"""Test _get_code_execution_tools without sandbox."""
agent = MockAgent()
tools = agent._get_code_execution_tools()
assert tools == []
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_code_execution_tools_with_sandbox(self, mock_manager_class):
"""Test _get_code_execution_tools with sandbox."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
tools = agent._get_code_execution_tools()
assert len(tools) == 2
# Check that tools are functions
assert callable(tools[0])
assert callable(tools[1])
async def test_sandbox_cleanup(self):
"""Test sandbox cleanup functionality."""
agent = MockAgent()
# Mock manager
mock_manager = AsyncMock()
agent._sandbox_manager = mock_manager
await agent.sandbox_cleanup()
mock_manager.__aexit__.assert_called_once_with(None, None, None)
assert agent._sandbox_manager is None
async def test_sandbox_cleanup_with_error(self):
"""Test sandbox cleanup with error handling."""
agent = MockAgent()
# Mock manager that raises exception
mock_manager = AsyncMock()
mock_manager.__aexit__.side_effect = Exception("Cleanup failed")
agent._sandbox_manager = mock_manager
# Should not raise, just log warning
await agent.sandbox_cleanup()
assert agent._sandbox_manager is None
@@ -1,203 +0,0 @@
"""
Unit tests for sandbox security module.
"""
import pytest
from praisonaiagents.sandbox.security import (
check_code_safety,
format_warnings,
SecurityWarning,
SecurityLevel,
SecurityAnalyzer
)
class TestCodeSafety:
"""Test code safety checking functionality."""
def test_empty_code(self):
"""Test empty code returns no warnings."""
warnings = check_code_safety("", "python")
assert warnings == []
def test_safe_code(self):
"""Test safe code returns no warnings."""
safe_code = """
x = 1 + 2
print(f"Result: {x}")
"""
warnings = check_code_safety(safe_code, "python")
assert warnings == []
def test_dangerous_imports(self):
"""Test detection of dangerous imports."""
dangerous_code = """
import os
import subprocess
os.system('rm -rf /')
"""
warnings = check_code_safety(dangerous_code, "python")
assert len(warnings) > 0
assert any("os.system" in str(w) for w in warnings)
def test_eval_exec_usage(self):
"""Test detection of eval/exec usage."""
dangerous_code = """
user_input = input("Enter code: ")
eval(user_input)
exec(user_input)
"""
warnings = check_code_safety(dangerous_code, "python")
assert len(warnings) > 0
assert any("eval" in str(w) or "exec" in str(w) for w in warnings)
def test_network_operations(self):
"""Test detection of network operations."""
network_code = """
import urllib.request
import socket
urllib.request.urlopen('http://example.com')
"""
warnings = check_code_safety(network_code, "python")
assert len(warnings) > 0
def test_file_operations(self):
"""Test detection of file operations."""
file_code = """
with open('/etc/passwd', 'r') as f:
data = f.read()
"""
warnings = check_code_safety(file_code, "python")
assert len(warnings) > 0
def test_bash_commands(self):
"""Test bash command safety checking."""
dangerous_bash = "rm -rf / --no-preserve-root"
warnings = check_code_safety(dangerous_bash, "bash")
assert len(warnings) > 0
def test_sql_injection_patterns(self):
"""Test SQL injection pattern detection."""
sql_code = """
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
"""
warnings = check_code_safety(sql_code, "python")
assert len(warnings) > 0
def test_format_warnings(self):
"""Test warning formatting."""
warnings = [
SecurityWarning("Test warning", SecurityLevel.HIGH, "eval", 1),
SecurityWarning("Another warning", SecurityLevel.MEDIUM, "import os", 2)
]
formatted = format_warnings(warnings)
assert "Test warning" in formatted
assert "Another warning" in formatted
assert "HIGH" in formatted
assert "MEDIUM" in formatted
def test_format_warnings_empty(self):
"""Test formatting empty warnings list."""
formatted = format_warnings([])
assert formatted == ""
class TestSecurityAnalyzer:
"""Test SecurityAnalyzer class."""
def test_analyzer_initialization(self):
"""Test SecurityAnalyzer initialization."""
analyzer = SecurityAnalyzer()
assert analyzer is not None
def test_analyze_safe_code(self):
"""Test analyzer with safe code."""
analyzer = SecurityAnalyzer()
code = "print('Hello, World!')"
warnings = analyzer.analyze(code, "python")
assert warnings == []
def test_analyze_unsafe_code(self):
"""Test analyzer with unsafe code."""
analyzer = SecurityAnalyzer()
code = "import os; os.system('rm -rf /')"
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0
assert all(isinstance(w, SecurityWarning) for w in warnings)
def test_different_languages(self):
"""Test analyzer with different languages."""
analyzer = SecurityAnalyzer()
# Python
python_warnings = analyzer.analyze("import os", "python")
assert len(python_warnings) > 0
# Bash
bash_warnings = analyzer.analyze("rm -rf /", "bash")
assert len(bash_warnings) > 0
# JavaScript (should fall back to basic checks)
js_warnings = analyzer.analyze("eval(userInput)", "javascript")
assert len(js_warnings) > 0
def test_security_levels(self):
"""Test different security levels are detected."""
analyzer = SecurityAnalyzer()
# High risk
high_risk_code = "import os; os.system('rm -rf /')"
warnings = analyzer.analyze(high_risk_code, "python")
high_warnings = [w for w in warnings if w.level == SecurityLevel.HIGH]
assert len(high_warnings) > 0
# Medium risk
medium_risk_code = "import subprocess"
warnings = analyzer.analyze(medium_risk_code, "python")
medium_warnings = [w for w in warnings if w.level == SecurityLevel.MEDIUM]
# May or may not have medium warnings depending on implementation
def test_line_numbers(self):
"""Test that line numbers are correctly reported."""
analyzer = SecurityAnalyzer()
code = """
print("Safe line")
import os
os.system('dangerous')
"""
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0
# Check that line numbers are reasonable
line_numbers = [w.line_number for w in warnings if w.line_number is not None]
assert all(ln > 0 for ln in line_numbers)
assert all(ln <= 4 for ln in line_numbers) # Within the code block
def test_pattern_matching(self):
"""Test that specific patterns are matched correctly."""
analyzer = SecurityAnalyzer()
test_cases = [
("eval(x)", ["eval"]),
("exec('code')", ["exec"]),
("__import__('os')", ["__import__"]),
("open('/etc/passwd')", ["file access"]),
("subprocess.call(['rm', 'file'])", ["subprocess"]),
]
for code, expected_patterns in test_cases:
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0, f"No warnings for: {code}"
# Check that at least one expected pattern is found
warning_texts = [str(w) for w in warnings]
found = any(
any(pattern.lower() in text.lower() for text in warning_texts)
for pattern in expected_patterns
)
assert found, f"Expected patterns {expected_patterns} not found in warnings for: {code}"
@@ -1,300 +0,0 @@
"""Unit tests for the ALLOWED_TOOLS filter module."""
import os
import logging
from unittest.mock import patch, MagicMock
import pytest
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter, filter_tools_with_allowed_tools
class TestAllowedToolsFilter:
"""Test cases for AllowedToolsFilter class."""
def test_filter_initialization_unset(self):
"""Test filter initialization with no environment variable set."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value is None
assert not filter_instance.is_enabled()
assert filter_instance.get_whitelist() is None
def test_filter_initialization_primary_var(self):
"""Test filter initialization with ALLOWED_TOOLS set."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_legacy_var(self):
"""Test filter initialization with HERMES_ONLY_TOOLS set (backward compatibility)."""
with patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_primary_precedence(self):
"""Test that ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "old_tool"
}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_empty_string_error(self):
"""Test that empty string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_whitespace_only_error(self):
"""Test that whitespace-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " "}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_empty_after_split_error(self):
"""Test that comma-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ",,,"}):
with pytest.raises(ValueError, match="contains no valid tool names"):
AllowedToolsFilter()
def test_parse_whitelist_with_spaces(self):
"""Test parsing comma-separated values with spaces."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " search , send_message , extract_pdf "}):
filter_instance = AllowedToolsFilter()
assert filter_instance.get_whitelist() == {"search", "send_message", "extract_pdf"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "false"})
def test_filter_tools_with_unknown_tools_dev_mode(self):
"""Test filtering tools with unknown tools in development mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return intersection of available and whitelisted tools
assert filtered == {"search", "send_message"}
# Should warn about unknown tools
mock_warn.assert_called()
call_args = mock_warn.call_args[0]
assert "Unknown tools" in call_args[0]
assert ["unknown_tool"] == call_args[2]
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "true"})
def test_filter_tools_with_unknown_tools_ci_mode(self):
"""Test filtering tools with unknown tools in CI mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_all_available(self):
"""Test filtering when all whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_partial_match(self):
"""Test filtering when only some whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_dict_input(self):
"""Test filtering with dictionary input."""
filter_instance = AllowedToolsFilter()
available_tools = {"search": lambda: None, "send_message": lambda: None, "extract_pdf": lambda: None}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_list_input(self):
"""Test filtering with list input."""
filter_instance = AllowedToolsFilter()
available_tools = ["search", "send_message", "extract_pdf"]
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message,unknown_in_whitelist"})
def test_diagnostics_data(self):
"""Test diagnostics data collection."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
diagnostics = filter_instance.get_diagnostics()
assert diagnostics["env_var_name"] == "ALLOWED_TOOLS"
assert diagnostics["env_value"] == "search,send_message,unknown_in_whitelist"
assert diagnostics["whitelist"] == ["search", "send_message", "unknown_in_whitelist"]
assert set(diagnostics["registered_before_filter"]) == {"search", "send_message", "extract_pdf"}
assert set(diagnostics["registered_after_filter"]) == {"search", "send_message"}
assert set(diagnostics["dropped_tools"]) == {"extract_pdf"}
assert set(diagnostics["unknown_tools"]) == {"unknown_in_whitelist"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_log_diagnostics(self, caplog):
"""Test diagnostics logging."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.INFO):
filter_instance.filter_tools(available_tools)
filter_instance.log_diagnostics()
log_text = caplog.text
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in log_text
assert "ALLOWED_TOOLS=search,send_message" in log_text
assert "RegisteredBeforeFilter" in log_text
assert "RegisteredAfterFilter" in log_text
assert "DroppedTools" in log_text
assert "UnknownTools" in log_text
def test_log_diagnostics_before_filtering(self, caplog):
"""Test logging diagnostics before calling filter_tools."""
filter_instance = AllowedToolsFilter()
with caplog.at_level(logging.WARNING):
filter_instance.log_diagnostics()
assert "No diagnostics available" in caplog.text
def test_filter_tools_unset_env_var(self, caplog):
"""Test filtering with unset environment variable."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.WARNING):
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
assert "Tool whitelist is unset" in caplog.text
@patch.dict(os.environ, {"ALLOWED_TOOLS": "tool1", "CI": "false"})
def test_no_matching_tools(self):
"""Test filtering when no whitelisted tools match available tools."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == set() # Empty set when no matches
def test_custom_env_var_name(self):
"""Test using custom environment variable name."""
with patch.dict(os.environ, {"CUSTOM_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter("CUSTOM_TOOLS")
assert filter_instance.env_var_name == "CUSTOM_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
class TestConvenienceFunction:
"""Test cases for the convenience function."""
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_with_allowed_tools(self):
"""Test the convenience function."""
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=False)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"CUSTOM_TOOLS": "search"})
def test_filter_tools_with_custom_env_var(self):
"""Test the convenience function with custom env var."""
available_tools = {"search", "send_message"}
filtered = filter_tools_with_allowed_tools(available_tools, env_var_name="CUSTOM_TOOLS", log_diagnostics=False)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search"})
def test_filter_tools_with_diagnostics(self, caplog):
"""Test the convenience function with diagnostics enabled."""
available_tools = {"search", "send_message"}
with caplog.at_level(logging.INFO):
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=True)
assert filtered == {"search"}
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in caplog.text
class TestBackwardCompatibility:
"""Test backward compatibility aliases."""
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_class_alias(self):
"""Test HermesToolFilter alias works."""
from praisonaiagents.allowed_tools_filter import HermesToolFilter
filter_instance = HermesToolFilter()
assert isinstance(filter_instance, AllowedToolsFilter)
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_function_aliases(self):
"""Test legacy function aliases work."""
from praisonaiagents.allowed_tools_filter import (
filter_tools_with_hermes, hermes_filter, apply_hermes_filter
)
available_tools = {"search", "send_message", "extract_pdf"}
# All aliases should work and return the same result
result1 = filter_tools_with_hermes(available_tools, log_diagnostics=False)
result2 = hermes_filter(available_tools, log_diagnostics=False)
result3 = apply_hermes_filter(available_tools, log_diagnostics=False)
expected = {"search", "send_message"}
assert result1 == expected
assert result2 == expected
assert result3 == expected
def test_empty_tools_edge_case(self):
"""Test filtering with empty available tools."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
filtered = filter_instance.filter_tools(set())
assert filtered == set()
# Also test with empty list and dict
assert filter_instance.filter_tools([]) == set()
assert filter_instance.filter_tools({}) == set()
def test_whitelist_immutability(self):
"""Test that get_whitelist returns a copy, not the original."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
whitelist1 = filter_instance.get_whitelist()
whitelist2 = filter_instance.get_whitelist()
# Should be equal but not the same object
assert whitelist1 == whitelist2
assert whitelist1 is not whitelist2
# Modifying one shouldn't affect the other
whitelist1.add("new_tool")
assert whitelist1 != whitelist2
@@ -21,16 +21,6 @@ import pytest
from unittest.mock import MagicMock
def _unregister_bot_platform(name: str) -> None:
"""Remove a runtime-registered bot platform (tests only)."""
from praisonai.bots._registry import get_default_bot_registry
try:
get_default_bot_registry().unregister(name.lower())
except (KeyError, ValueError):
pass
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helper: Mock adapter that simulates a platform bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -69,10 +59,9 @@ class TestBotWithMockAdapter:
"""Integration tests for Bot class with a mock platform adapter."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@@ -159,14 +148,10 @@ class TestBotOSLifecycle:
"""Integration tests for BotOS multi-bot orchestration."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mock1", MockPlatformAdapter)
register_platform("mock2", MockPlatformAdapter)
self._cleanup = lambda: (
_unregister_bot_platform("mock1"),
_unregister_bot_platform("mock2"),
)
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
def teardown_method(self):
self._cleanup()
@@ -227,10 +212,9 @@ class TestRealAgentInBot:
"""Tests with a real praisonaiagents.Agent inside Bot."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@@ -262,10 +246,9 @@ class TestAgentTeamInBotOS:
"""AgentTeam (multi-agent) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@@ -317,10 +300,9 @@ class TestAgentFlowInBotOS:
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@@ -373,8 +355,9 @@ class TestPlatformExtensibility:
"""Third-party platform registration and usage."""
def teardown_method(self):
_unregister_bot_platform("custom_chat")
_unregister_bot_platform("my_platform")
from praisonai.bots._registry import _custom_platforms
_custom_platforms.pop("custom_chat", None)
_custom_platforms.pop("my_platform", None)
def test_register_and_use_custom_platform(self):
from praisonai.bots._registry import register_platform, resolve_adapter
@@ -406,7 +389,7 @@ class TestPlatformExtensibility:
def test_resolve_unknown_platform_raises(self):
from praisonai.bots._registry import resolve_adapter
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
with pytest.raises(ValueError, match="Unknown platform"):
resolve_adapter("nonexistent_xyz")
@@ -418,10 +401,9 @@ class TestBotOSFromConfig:
"""BotOS.from_config() YAML config loading."""
def setup_method(self):
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@@ -477,8 +459,7 @@ platforms:
def test_from_config_multi_platform(self):
from praisonai.bots import BotOS
from praisonai.bots._registry import register_platform
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mock2", MockPlatformAdapter)
yaml_content = """\
@@ -503,7 +484,7 @@ platforms:
assert "mock2" in botos.list_bots()
finally:
os.unlink(path)
_unregister_bot_platform("mock2")
_custom_platforms.pop("mock2", None)
def test_from_config_invalid_raises(self):
from praisonai.bots import BotOS
@@ -1,10 +0,0 @@
"""@url mentions must not fetch loopback targets."""
from praisonaiagents.tools.mentions import MentionsParser
def test_url_mention_blocks_loopback():
parser = MentionsParser()
result = parser._process_url_mention("http://127.0.0.1:8765/")
assert result is not None
assert "Blocked" in result
@@ -361,11 +361,3 @@ class TestProfileExports:
from praisonaiagents.tools import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
def test_import_execute_code_uses_module_function(self):
"""execute_code should resolve to python_tools.execute_code function."""
from praisonaiagents.tools import execute_code
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
assert callable(execute_code)
assert execute_code is execute_code_impl
@@ -112,22 +112,6 @@ class TestSandboxEscapePrevention:
result = sandbox.run("__import__('os')")
assert result["success"] is False
def test_print_self_blocked(self, sandbox):
"""print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
result = sandbox.run("b = print.__self__")
assert result["success"] is False
assert "restricted" in result["stderr"].lower()
def test_vars_call_blocked(self, sandbox):
"""vars() can expose builtins.__dict__ after __self__ leak."""
result = sandbox.run("vars({})")
assert result["success"] is False
def test_attribute_dunder_call_blocked(self, sandbox):
"""Attribute calls bypass bare-name Call checks."""
result = sandbox.run("(1).__class__.__mro__")
assert result["success"] is False
# ── Legitimate Code (all must PASS) ─────────────────────────────────────────
@@ -38,16 +38,6 @@ def test_still_blocks_loopback():
assert spider._validate_url("http://localhost/") is False
def test_blocks_alternate_loopback_encodings():
"""GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
spider = SpiderTools()
assert spider._validate_url("http://localhost.:8765/") is False
assert spider._validate_url("http://127.1:8765/") is False
assert spider._validate_url("http://0177.0.0.1:8765/") is False
assert spider._validate_url("http://0x7f000001:8765/") is False
assert spider._validate_url("http://2130706433:8765/") is False
def test_rejects_non_string_input():
spider = SpiderTools()
assert spider._validate_url(None) is False # type: ignore[arg-type]
+466 -828
View File
File diff suppressed because it is too large. Load diff
@@ -7,18 +7,12 @@ Usage::
"""
import argparse
import os
import sys
def main() -> None:
default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
parser.add_argument(
"--host",
default=default_host,
help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
)
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
@@ -71,57 +71,3 @@ async def require_workspace_member(
)
user.workspace_id = workspace_id
return user
async def require_workspace_admin(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require admin or owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="admin"
)
async def require_workspace_owner(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="owner"
)
def ensure_resource_in_workspace(
resource_workspace_id: str | None,
workspace_id: str,
*,
label: str = "Resource",
) -> None:
"""Reject cross-workspace access (IDOR) with a generic 404."""
if resource_workspace_id != workspace_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{label} not found",
)
async def require_issue_in_workspace(
workspace_id: str,
issue_id: str,
session: AsyncSession,
):
"""Load an issue and verify it belongs to the URL workspace."""
from ..db.models import Issue
issue = await session.get(Issue, issue_id)
if issue is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue not found",
)
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
return issue
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ..deps import get_db, require_workspace_member
from ..schemas import ActivityLogResponse
from ...services.activity_service import ActivityService
@@ -38,7 +38,6 @@ async def list_issue_activity(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = ActivityService(session)
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
return [ActivityLogResponse.model_validate(log) for log in logs]
@@ -58,7 +58,7 @@ async def get_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.get(agent_id, workspace_id=workspace_id)
agent = await svc.get(agent_id)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentResponse.model_validate(agent)
@@ -75,7 +75,6 @@ async def update_agent(
svc = AgentService(session)
agent = await svc.update(
agent_id,
workspace_id=workspace_id,
name=body.name,
status=body.status,
instructions=body.instructions,
@@ -96,6 +95,6 @@ async def delete_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
deleted = await svc.delete(agent_id)
if not deleted:
raise HTTPException(status_code=404, detail="Agent not found")
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ...services.dependency_service import DependencyService
from ..deps import get_db, require_workspace_member
from ..schemas import DependencyCreate, DependencyResponse
from ...services.dependency_service import DependencyService
router = APIRouter(
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
@@ -27,10 +27,6 @@ async def create_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
await require_issue_in_workspace(
workspace_id, body.depends_on_issue_id, session
)
svc = DependencyService(session)
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
return DependencyResponse.model_validate(dep)
@@ -43,7 +39,6 @@ async def list_dependencies(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
deps = await svc.list_for_issue(issue_id)
return [DependencyResponse.model_validate(d) for d in deps]
@@ -57,13 +52,7 @@ async def delete_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
dep = await svc.get(dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="Dependency not found")
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
raise HTTPException(status_code=404, detail="Dependency not found")
deleted = await svc.delete(dep_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dependency not found")
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
from ..deps import get_db, require_workspace_member
from ..schemas import (
CommentCreate,
CommentResponse,
@@ -87,7 +87,7 @@ async def get_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.get(issue_id, workspace_id=workspace_id)
issue = await svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
return IssueResponse.model_validate(issue)
@@ -104,7 +104,6 @@ async def update_issue(
svc = IssueService(session)
issue = await svc.update(
issue_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -133,7 +132,7 @@ async def delete_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
deleted = await svc.delete(issue_id)
if not deleted:
raise HTTPException(status_code=404, detail="Issue not found")
@@ -149,11 +148,6 @@ async def add_comment(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comment = await svc.create(
issue_id=issue_id,
@@ -172,11 +166,6 @@ async def list_comments(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comments = await svc.list_for_issue(issue_id)
return [CommentResponse.model_validate(c) for c in comments]
@@ -9,12 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import (
ensure_resource_in_workspace,
get_db,
require_issue_in_workspace,
require_workspace_member,
)
from ..deps import get_db, require_workspace_member
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
from ...services.label_service import LabelService
@@ -53,10 +48,6 @@ async def update_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
label = await svc.update(label_id, body.name, body.color)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
@@ -71,10 +62,6 @@ async def delete_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
deleted = await svc.delete(label_id)
if not deleted:
raise HTTPException(status_code=404, detail="Label not found")
@@ -91,12 +78,7 @@ async def add_label_to_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.add_to_issue(issue_id, label_id)
@@ -108,12 +90,7 @@ async def remove_label_from_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.remove_from_issue(issue_id, label_id)
@@ -124,7 +101,6 @@ async def list_issue_labels(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
labels = await svc.list_for_issue(issue_id)
return [LabelResponse.model_validate(l) for l in labels]
@@ -56,7 +56,7 @@ async def get_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id, workspace_id=workspace_id)
project = await svc.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@@ -73,7 +73,6 @@ async def update_project(
svc = ProjectService(session)
project = await svc.update(
project_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -93,7 +92,7 @@ async def delete_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
deleted = await svc.delete(project_id, workspace_id=workspace_id)
deleted = await svc.delete(project_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
@@ -106,7 +105,4 @@ async def project_stats(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id, workspace_id=workspace_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return await svc.get_stats(project_id)
@@ -9,13 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import (
get_current_user,
get_db,
require_workspace_admin,
require_workspace_member,
require_workspace_owner,
)
from ..deps import get_current_user, get_db, require_workspace_member
from ..schemas import (
MemberAdd,
MemberResponse,
@@ -70,7 +64,7 @@ async def get_workspace(
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: AuthIdentity = Depends(require_workspace_admin),
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -83,7 +77,7 @@ async def update_workspace(
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: AuthIdentity = Depends(require_workspace_owner),
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -99,16 +93,10 @@ async def delete_workspace(
async def add_member(
workspace_id: str,
body: MemberAdd,
user: AuthIdentity = Depends(require_workspace_admin),
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if body.role in ("owner", "admin"):
if not await member_svc.has_role(workspace_id, user.id, "owner"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can add admin or owner roles",
)
member = await member_svc.add(workspace_id, body.user_id, body.role)
return MemberResponse.model_validate(member)
@@ -129,32 +117,10 @@ async def update_member_role(
workspace_id: str,
user_id: str,
body: MemberUpdate,
user: AuthIdentity = Depends(require_workspace_admin),
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if user_id == user.id and body.role != target.role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot change your own role",
)
if body.role in ("owner", "admin") and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can assign admin or owner roles",
)
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can change an owner's role",
)
member = await member_svc.update_role(workspace_id, user_id, body.role)
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
@@ -165,25 +131,10 @@ async def update_member_role(
async def remove_member(
workspace_id: str,
user_id: str,
user: AuthIdentity = Depends(require_workspace_admin),
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if user_id == user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot remove yourself from the workspace",
)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can remove an owner",
)
removed = await member_svc.remove(workspace_id, user_id)
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
@@ -50,16 +50,9 @@ class AgentService:
await self._session.flush()
return agent
async def get(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Agent]:
"""Get agent by ID, optionally scoped to a workspace."""
agent = await self._session.get(Agent, agent_id)
if agent is None:
return None
if workspace_id is not None and agent.workspace_id != workspace_id:
return None
return agent
async def get(self, agent_id: str) -> Optional[Agent]:
"""Get agent by ID."""
return await self._session.get(Agent, agent_id)
async def list_for_workspace(
self,
@@ -79,8 +72,6 @@ class AgentService:
async def update(
self,
agent_id: str,
*,
workspace_id: Optional[str] = None,
name: Optional[str] = None,
status: Optional[str] = None,
instructions: Optional[str] = None,
@@ -89,7 +80,7 @@ class AgentService:
max_concurrent_tasks: Optional[int] = None,
) -> Optional[Agent]:
"""Update agent fields."""
agent = await self.get(agent_id, workspace_id=workspace_id)
agent = await self.get(agent_id)
if agent is None:
return None
if name is not None:
@@ -111,11 +102,9 @@ class AgentService:
await self._session.flush()
return agent
async def delete(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> bool:
async def delete(self, agent_id: str) -> bool:
"""Delete an agent."""
agent = await self.get(agent_id, workspace_id=workspace_id)
agent = await self.get(agent_id)
if agent is None:
return False
await self._session.delete(agent)
@@ -113,10 +113,6 @@ class AuthService:
def _issue_token(self, user: User) -> str:
"""Issue a JWT for a user."""
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
)
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
@@ -69,16 +69,9 @@ class IssueService:
await self._session.flush()
return issue
async def get(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
"""Get issue by ID, optionally scoped to a workspace."""
issue = await self._session.get(Issue, issue_id)
if issue is None:
return None
if workspace_id is not None and issue.workspace_id != workspace_id:
return None
return issue
async def get(self, issue_id: str) -> Optional[Issue]:
"""Get issue by ID."""
return await self._session.get(Issue, issue_id)
async def list_for_workspace(
self,
@@ -104,8 +97,6 @@ class IssueService:
async def update(
self,
issue_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -115,7 +106,7 @@ class IssueService:
project_id: Optional[str] = None,
) -> Optional[Issue]:
"""Update issue fields."""
issue = await self.get(issue_id, workspace_id=workspace_id)
issue = await self.get(issue_id)
if issue is None:
return None
if title is not None:
@@ -144,27 +135,21 @@ class IssueService:
issue_id: str,
assignee_type: str,
assignee_id: str,
*,
workspace_id: Optional[str] = None,
) -> Optional[Issue]:
"""Assign an issue to a member or agent."""
if assignee_type not in VALID_ASSIGNEE_TYPES:
raise ValueError(f"Invalid assignee_type: {assignee_type}")
return await self.update(
issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
)
async def transition(
self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
"""Transition an issue to a new status."""
return await self.update(issue_id, workspace_id=workspace_id, status=new_status)
return await self.update(issue_id, status=new_status)
async def delete(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> bool:
async def delete(self, issue_id: str) -> bool:
"""Delete an issue."""
issue = await self.get(issue_id, workspace_id=workspace_id)
issue = await self.get(issue_id)
if issue is None:
return False
await self._session.delete(issue)
@@ -44,16 +44,9 @@ class ProjectService:
await self._session.flush()
return project
async def get(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Project]:
"""Get project by ID, optionally scoped to a workspace."""
project = await self._session.get(Project, project_id)
if project is None:
return None
if workspace_id is not None and project.workspace_id != workspace_id:
return None
return project
async def get(self, project_id: str) -> Optional[Project]:
"""Get project by ID."""
return await self._session.get(Project, project_id)
async def list_for_workspace(
self,
@@ -69,8 +62,6 @@ class ProjectService:
async def update(
self,
project_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -78,7 +69,7 @@ class ProjectService:
lead_id: Optional[str] = None,
) -> Optional[Project]:
"""Update project fields."""
project = await self.get(project_id, workspace_id=workspace_id)
project = await self.get(project_id)
if project is None:
return None
if title is not None:
@@ -94,11 +85,9 @@ class ProjectService:
await self._session.flush()
return project
async def delete(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> bool:
async def delete(self, project_id: str) -> bool:
"""Delete a project."""
project = await self.get(project_id, workspace_id=workspace_id)
project = await self.get(project_id)
if project is None:
return False
await self._session.delete(project)
@@ -10,7 +10,7 @@ from __future__ import annotations
import re
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Member, Workspace
@@ -98,13 +98,10 @@ class WorkspaceService:
return ws
async def delete(self, workspace_id: str) -> bool:
"""Delete a workspace and its memberships."""
"""Delete a workspace."""
ws = await self.get(workspace_id)
if ws is None:
return False
await self._session.execute(
delete(Member).where(Member.workspace_id == workspace_id)
)
await self._session.delete(ws)
await self._session.flush()
return True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonai-platform"
version = "0.1.4"
version = "0.1.2"
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
readme = "README.md"
requires-python = ">=3.10"
@@ -1,20 +0,0 @@
"""Cross-workspace IDOR guards for platform resources."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_project_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
assert exc.value.status_code == 404
def test_label_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
assert exc.value.status_code == 404
@@ -1,38 +0,0 @@
"""Service-layer workspace scoping for issues and projects."""
from __future__ import annotations
import pytest
from praisonai_platform.services.auth_service import AuthService
from praisonai_platform.services.issue_service import IssueService
from praisonai_platform.services.project_service import ProjectService
from praisonai_platform.services.workspace_service import WorkspaceService
@pytest.mark.asyncio
async def test_issue_get_rejects_wrong_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("A", user.id)
ws_b = await WorkspaceService(session).create("B", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(
workspace_id=ws_a.id,
title="secret",
creator_id=user.id,
)
assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
@pytest.mark.asyncio
async def test_project_delete_scoped_to_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("PA", user.id)
ws_b = await WorkspaceService(session).create("PB", user.id)
proj_svc = ProjectService(session)
project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
@@ -1,18 +0,0 @@
"""Workspace member RBAC and cross-workspace IDOR guards."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_ensure_resource_in_workspace_rejects_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
assert exc.value.status_code == 404
def test_ensure_resource_in_workspace_allows_match():
ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
+1 -1
View File
@@ -1208,7 +1208,7 @@ wheels = [
[[package]]
name = "praisonai-platform"
version = "0.1.4"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"lastUpdated": "2026-05-08",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1725,7 +1725,6 @@
"planning": [],
"plugins": [
"PluginRegistry",
"ToolDefinition",
"disable_plugins",
"enable_plugins",
"get_plugin_manager",
@@ -1750,6 +1749,7 @@
"tools": [
"FunctionTool",
"Tool",
"ToolDefinition",
"ToolRegistry",
"ToolResult"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"lastUpdated": "2026-05-08",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more