mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251470489f | ||
|
|
dbf8b0d6e1 | ||
|
|
c83aa5a35d | ||
|
|
9543799f95 | ||
|
|
7719a47892 | ||
|
|
7873afd6c9 | ||
|
|
8d05e93c21 | ||
|
|
8c2ef4102b | ||
|
|
5796583019 | ||
|
|
11c7e370e1 | ||
|
|
92e4540e81 | ||
|
|
69e0e91623 | ||
|
|
613309f39b | ||
|
|
6dbb645ba6 | ||
|
|
a4428fd0d0 | ||
|
|
348904557c | ||
|
|
9a5c1bce9e | ||
|
|
fee8e581f4 | ||
|
|
0d43cd98f2 | ||
|
|
fdcbdc7640 | ||
|
|
d6a1a26685 | ||
|
|
31967f6a65 | ||
|
|
456c4d5853 | ||
|
|
efc01ab3d8 | ||
|
|
88c86db05d | ||
|
|
12d1811f0c | ||
|
|
6012d36dfd | ||
|
|
8a4e8a3a43 | ||
|
|
73c9ed3532 | ||
|
|
0f17585315 | ||
|
|
2660a5d6ad | ||
|
|
97e4285511 | ||
|
|
fcb1f249c2 | ||
|
|
6bc240be22 | ||
|
|
a72e156c4d | ||
|
|
24385d6487 | ||
|
|
2f8162207f | ||
|
|
516d8b6d88 | ||
|
|
7eb98e895b | ||
|
|
ef79b7a056 | ||
|
|
3ea8376610 | ||
|
|
6360b24c06 | ||
|
|
b0d8f77752 | ||
|
|
cce21bc8f1 | ||
|
|
179cab02db | ||
|
|
402d7ed9fc | ||
|
|
701e8e6a6a | ||
|
|
ef09341cc0 | ||
|
|
3a4d7e11c8 | ||
|
|
5900d8bba6 | ||
|
|
75e9085eb4 | ||
|
|
bfba25efaf | ||
|
|
92644518c0 | ||
|
|
cf40b25e65 | ||
|
|
cd54574b20 | ||
|
|
9fd72a6455 | ||
|
|
437db5c437 | ||
|
|
68035af76d | ||
|
|
491e8950a3 | ||
|
|
7a25523c7d | ||
|
|
c989f2c31e | ||
|
|
a2c51a0760 | ||
|
|
7a601b5373 | ||
|
|
12f430e29c | ||
|
|
c3c78fbc7b | ||
|
|
bf120c8dca | ||
|
|
13d073b02a | ||
|
|
adcfa29941 | ||
|
|
7efd0075da | ||
|
|
9ec90361d6 | ||
|
|
2e5540a41b | ||
|
|
7fcd6af4c6 | ||
|
|
1fb71c46b4 | ||
|
|
ca46eb1297 | ||
|
|
422212105a | ||
|
|
b2611e5d61 | ||
|
|
78f0a71e71 | ||
|
|
e01f63dba2 | ||
|
|
99638a9129 | ||
|
|
a410bc88bd | ||
|
|
7bcdfafb4d | ||
|
|
a41a8a22d6 | ||
|
|
c18450bb35 | ||
|
|
9955d65f82 | ||
|
|
80c78071fc | ||
|
|
2e05f2c0f2 | ||
|
|
4985415e61 | ||
|
|
a29deefef7 | ||
|
|
7ffb4f87c5 | ||
|
|
057dab0ec0 | ||
|
|
89229c5c76 | ||
|
|
e20e426dd5 | ||
|
|
d49c559fce | ||
|
|
de84d22087 | ||
|
|
f3166b5bbd | ||
|
|
0d1cc32b23 | ||
|
|
e33b4b4b39 | ||
|
|
c9bbb5aee7 | ||
|
|
ed63d34ed7 | ||
|
|
d15c57d80c | ||
|
|
73eaacad35 | ||
|
|
0cec9fd1c3 | ||
|
|
7a7df247c8 | ||
|
|
cb96d03b58 |
No files matched your search
@@ -0,0 +1,51 @@
|
||||
# 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
|
||||
@@ -181,12 +181,15 @@ 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:${{ steps.check_fork.outputs.pr_branch }}
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
git remote set-url origin file://$(pwd)
|
||||
fi
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
@@ -63,10 +60,12 @@ 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="${{ inputs.issue_number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
|
||||
else
|
||||
export ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_EVENT"
|
||||
fi
|
||||
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
|
||||
@@ -30,22 +30,23 @@ 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/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$ISSUE_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$PR_NUMBER_INPUT/head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=$PR_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
|
||||
|
||||
|
||||
@@ -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,6 +176,18 @@ 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 != ''
|
||||
|
||||
@@ -294,8 +294,35 @@ def search(query: str) -> str:
|
||||
|
||||
@tool
|
||||
def calculate(expression: str) -> float:
|
||||
"""Evaluate a math expression."""
|
||||
return eval(expression)
|
||||
"""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")
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
@@ -304,6 +331,7 @@ 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)
|
||||
@@ -330,6 +358,15 @@ 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
|
||||
|
||||
@@ -183,6 +183,7 @@ 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>
|
||||
@@ -786,6 +787,10 @@ Methods:
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <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>
|
||||
@@ -853,6 +858,7 @@ 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>
|
||||
|
||||
@@ -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.35" \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.35" \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.35" \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Linear Agent Example for PraisonAI.
|
||||
|
||||
Demonstrates how to create a Linear bot that responds to issue mentions
|
||||
and assignments with full coding capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import LinearBot
|
||||
|
||||
# Create an agent with Linear tools and coding capabilities
|
||||
agent = Agent(
|
||||
name="PraisonAI Coder",
|
||||
instructions="""
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[
|
||||
"linear_search_issues",
|
||||
"linear_get_issue",
|
||||
"linear_add_comment",
|
||||
"linear_update_issue",
|
||||
"linear_list_teams",
|
||||
"linear_list_issue_states",
|
||||
"github_create_branch",
|
||||
"github_commit_and_push",
|
||||
"github_create_pull_request",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"execute_command"
|
||||
],
|
||||
memory=True,
|
||||
web_search=True,
|
||||
auto_approve_tools=True,
|
||||
)
|
||||
|
||||
# Create Linear bot
|
||||
bot = LinearBot(
|
||||
token="your-linear-oauth-token", # or set LINEAR_OAUTH_TOKEN env var
|
||||
signing_secret="your-webhook-secret", # or set LINEAR_WEBHOOK_SECRET env var
|
||||
agent=agent,
|
||||
webhook_port=8080,
|
||||
)
|
||||
|
||||
async def main():
|
||||
"""Start the Linear bot."""
|
||||
print("Starting Linear bot...")
|
||||
print("Set up your Linear webhook to point to: http://your-host:8080/webhook")
|
||||
print("Configure AgentSession and Comment events in Linear webhook settings")
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
await bot.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -23,12 +23,39 @@ 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")
|
||||
try:
|
||||
val = eval(expr, {"__builtins__": {}})
|
||||
except Exception:
|
||||
val = "error"
|
||||
val = _safe_calc(str(expr))
|
||||
print(f" [Calculator: {expr} = {val}]")
|
||||
return str(val)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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."
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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)
|
||||
@@ -44,12 +44,33 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
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__": {}})
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
platform: linear
|
||||
token: ${LINEAR_OAUTH_TOKEN}
|
||||
signing_secret: ${LINEAR_WEBHOOK_SECRET}
|
||||
webhook_port: 8080
|
||||
|
||||
agent:
|
||||
name: "PraisonAI Coder"
|
||||
instructions: |
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- linear_search_issues
|
||||
- linear_get_issue
|
||||
- linear_add_comment
|
||||
- linear_update_issue
|
||||
- linear_list_teams
|
||||
- linear_list_issue_states
|
||||
- github_create_branch
|
||||
- github_commit_and_push
|
||||
- github_create_pull_request
|
||||
- read_file
|
||||
- write_file
|
||||
- execute_command
|
||||
memory: true
|
||||
web_search: true
|
||||
auto_approve_tools: true
|
||||
@@ -21,6 +21,7 @@ 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__)
|
||||
@@ -255,7 +256,7 @@ if TYPE_CHECKING:
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(SandboxMixin, 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()
|
||||
@@ -519,6 +520,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model: Optional[Union[str, Any]] = None, # Alias for llm=
|
||||
base_url: Optional[str] = None, # Kept separate (connection/auth)
|
||||
api_key: Optional[str] = None, # Kept separate (connection/auth)
|
||||
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
|
||||
# Tools
|
||||
tools: Optional[List[Any]] = None,
|
||||
allow_delegation: bool = False, # Deprecated: use handoffs= instead
|
||||
@@ -554,6 +556,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
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.
|
||||
|
||||
@@ -1521,6 +1524,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
@@ -1531,6 +1536,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
@@ -1552,6 +1558,10 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
if api_key and 'api_key' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['api_key'] = api_key
|
||||
# Add auth if provided and not in dict
|
||||
if auth and 'auth' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['auth'] = auth
|
||||
# Add metrics parameter
|
||||
llm = llm.copy()
|
||||
llm['metrics'] = metrics
|
||||
@@ -1571,6 +1581,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
@@ -1936,6 +1948,9 @@ 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):
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
@@ -45,9 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
import traceback
|
||||
from typing import List, Optional, Any, Dict, Union, Callable, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -560,7 +556,11 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
|
||||
# C7 — honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
# Emit LLM request trace event (zero overhead when not set)
|
||||
@@ -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,
|
||||
session_id=session_id
|
||||
context={"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', None),
|
||||
execute_tool_fn=getattr(self, 'execute_tool_async', 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,6 +935,151 @@ 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.
|
||||
|
||||
@@ -1149,7 +1294,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -1193,11 +1338,17 @@ Your Goal: {self.goal}"""
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice)
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal chat implementation (extracted for trace wrapping)."""
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
@@ -1418,7 +1569,16 @@ Your Goal: {self.goal}"""
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
if effective_tool_choice:
|
||||
llm_kwargs['tool_choice'] = effective_tool_choice
|
||||
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
|
||||
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = self.llm_instance.get_response(**llm_kwargs)
|
||||
|
||||
self._add_to_chat_history("assistant", response_text)
|
||||
@@ -1714,7 +1874,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
cleaned = cleaned[:-3].strip()
|
||||
return cleaned
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None):
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -1737,13 +1897,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_steps=reasoning_steps, stream=stream,
|
||||
task_name=task_name, task_description=task_description, task_id=task_id,
|
||||
config=config, force_retrieval=force_retrieval, skip_retrieval=skip_retrieval,
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
|
||||
seed=seed, cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal async chat implementation (extracted for trace wrapping)."""
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
# Use agent's stream setting if not explicitly provided
|
||||
if stream is None:
|
||||
stream = self.stream
|
||||
@@ -1866,31 +2033,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
try:
|
||||
response_text = await self.llm_instance.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=self.chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
console=self.console,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool_async,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
# C1 — per-call seed forwarding (async path)
|
||||
llm_kwargs = {
|
||||
'prompt': prompt,
|
||||
'system_prompt': self._build_system_prompt(tools),
|
||||
'chat_history': self.chat_history,
|
||||
'temperature': temperature,
|
||||
'tools': tools,
|
||||
'output_json': output_json,
|
||||
'output_pydantic': output_pydantic,
|
||||
'verbose': self.verbose,
|
||||
'markdown': self.markdown,
|
||||
'reflection': self.self_reflect,
|
||||
'max_reflect': self.max_reflect,
|
||||
'min_reflect': self.min_reflect,
|
||||
'console': self.console,
|
||||
'agent_name': self.name,
|
||||
'agent_role': self.role,
|
||||
'agent_tools': [t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
'execute_tool_fn': self.execute_tool_async,
|
||||
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'stream': stream
|
||||
}
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
|
||||
reason = getattr(_cancel, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
|
||||
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
@@ -2008,8 +2187,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
# Continue to handle structured outputs and other processing
|
||||
# Don't return immediately - fall through to existing logic
|
||||
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,
|
||||
)
|
||||
else:
|
||||
# LEGACY: Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
|
||||
@@ -164,7 +164,8 @@ 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
|
||||
@@ -204,7 +205,8 @@ 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
|
||||
@@ -365,6 +367,8 @@ 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
|
||||
@@ -405,6 +409,8 @@ 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
|
||||
@@ -451,6 +457,8 @@ 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
|
||||
@@ -495,6 +503,8 @@ 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
|
||||
@@ -5,12 +5,10 @@ Contains all methods for run/start/launch lifecycle, planning,
|
||||
and server endpoints. Extracted from agent.py for maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import inspect
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
@@ -126,7 +124,7 @@ class ExecutionMixin:
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE),
|
||||
)
|
||||
|
||||
async def arun(self, prompt: str, **kwargs):
|
||||
async def arun(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Async version of run() - silent, non-streaming, returns structured result.
|
||||
|
||||
Production-friendly async execution. Does not stream or display output.
|
||||
|
||||
@@ -9,7 +9,6 @@ for maintainability.
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import List, Optional, Any, Dict, Generator, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,7 +81,7 @@ class MemoryMixin:
|
||||
with self._history_lock:
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content):
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
|
||||
"""Thread-safe method to add messages to chat history only if not duplicate.
|
||||
|
||||
Atomically checks for duplicate and adds message under the same lock to prevent TOCTOU races.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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]
|
||||
@@ -194,6 +194,21 @@ class ToolExecutionMixin:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# C4 — optional tool-argument validation via ToolValidatorProtocol.
|
||||
# Zero overhead when not set. Users wire via `agent._tool_validator = MyValidator()`.
|
||||
_validator = getattr(self, '_tool_validator', None)
|
||||
if _validator is not None:
|
||||
try:
|
||||
_vres = _validator.validate_args(function_name, arguments)
|
||||
if _vres is not None and not getattr(_vres, 'valid', True):
|
||||
_errs = "; ".join(getattr(_vres, 'errors', []) or ["validation failed"])
|
||||
logging.warning(
|
||||
f"Tool {function_name} args rejected by validator: {_errs}"
|
||||
)
|
||||
return f"Tool arguments rejected: {_errs}"
|
||||
except Exception as _ve: # noqa: BLE001 — never break tool exec on validator bug
|
||||
logging.debug(f"Tool validator raised; skipping validation: {_ve}")
|
||||
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
@@ -758,11 +773,9 @@ class ToolExecutionMixin:
|
||||
pass
|
||||
|
||||
if func is None:
|
||||
# If not found in tools or registry, try globals and main
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
pass
|
||||
|
||||
if func:
|
||||
try:
|
||||
|
||||
@@ -126,7 +126,24 @@ class _AgentServerRegistry:
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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
|
||||
@@ -14,6 +14,13 @@ Quick start::
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
|
||||
Subscription auth (lazy-loaded)::
|
||||
|
||||
from praisonaiagents.auth import resolve_subscription_credentials
|
||||
|
||||
creds = resolve_subscription_credentials("claude-code")
|
||||
print(creds.api_key, creds.headers)
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -25,7 +32,36 @@ from .protocols import (
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
# Subscription auth (lazy-loaded)
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy-load subscription auth to avoid import overhead."""
|
||||
if name in (
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .subscription import (
|
||||
SubscriptionAuthProtocol,
|
||||
SubscriptionCredentials,
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Subscription / OAuth auth for LLM providers.
|
||||
|
||||
Opt-in, zero-overhead. Lazy-loaded via __getattr__ so importing
|
||||
praisonaiagents stays fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("SubscriptionAuthProtocol", "SubscriptionCredentials"):
|
||||
from .protocols import SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
return locals()[name]
|
||||
if name in (
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Claude Code OAuth — read ~/.claude/.credentials.json or macOS Keychain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
|
||||
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
"""
|
||||
cached = getattr(_detect_claude_code_version, "_cache", None)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["claude", "--version"], capture_output=True, text=True, timeout=2, check=False
|
||||
)
|
||||
match = re.match(r"(\d+\.\d+\.\d+)", out.stdout.strip())
|
||||
version = match.group(1) if match else _CLAUDE_CODE_VERSION_FALLBACK
|
||||
except Exception:
|
||||
version = _CLAUDE_CODE_VERSION_FALLBACK
|
||||
_detect_claude_code_version._cache = version
|
||||
return version
|
||||
|
||||
|
||||
_CLI_USER_AGENT_FALLBACK = f"claude-cli/{_CLAUDE_CODE_VERSION_FALLBACK} (external, cli)"
|
||||
|
||||
|
||||
def _read_keychain_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read 'Claude Code-credentials' entry from macOS Keychain.
|
||||
|
||||
Returns dict with accessToken/refreshToken/expiresAt or None.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
return None
|
||||
data = json.loads(out.stdout)
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if oauth.get("accessToken"):
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-keychain",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read ~/.claude/.credentials.json."""
|
||||
path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if not oauth.get("accessToken"):
|
||||
return None
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-file",
|
||||
}
|
||||
|
||||
|
||||
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
|
||||
if not expires_at_ms:
|
||||
return False
|
||||
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
|
||||
|
||||
|
||||
def _refresh(refresh_token: str) -> Dict[str, Any]:
|
||||
"""Pure refresh — does not touch local files."""
|
||||
if not refresh_token:
|
||||
raise AuthError("no refresh_token; please re-run 'claude /login'")
|
||||
body = urllib.parse.urlencode({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
}).encode()
|
||||
last = None
|
||||
for endpoint in _TOKEN_ENDPOINTS:
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return {
|
||||
"accessToken": payload["access_token"],
|
||||
"refreshToken": payload.get("refresh_token", refresh_token),
|
||||
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
|
||||
}
|
||||
except Exception as exc:
|
||||
last = exc
|
||||
raise AuthError(f"Anthropic refresh failed: {last}")
|
||||
|
||||
|
||||
class ClaudeCodeAuth:
|
||||
"""Claude Code OAuth subscription auth."""
|
||||
|
||||
name = "claude-code"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# 1. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env (Hermes-style)
|
||||
for env_key in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
tok = os.environ.get(env_key, "").strip()
|
||||
if tok:
|
||||
return SubscriptionCredentials(
|
||||
api_key=tok,
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
source=f"env:{env_key}",
|
||||
)
|
||||
|
||||
# 2. Keychain or file
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds:
|
||||
raise AuthError(
|
||||
"No Claude Code credentials found. Install Claude Code and run "
|
||||
"'claude /login', or set ANTHROPIC_TOKEN."
|
||||
)
|
||||
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
|
||||
creds.update(_refresh(creds["refreshToken"]))
|
||||
return SubscriptionCredentials(
|
||||
api_key=creds["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=creds.get("expiresAt"),
|
||||
source=creds["source"],
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds or not creds.get("refreshToken"):
|
||||
raise AuthError("cannot refresh: no refresh token available")
|
||||
new = _refresh(creds["refreshToken"])
|
||||
return SubscriptionCredentials(
|
||||
api_key=new["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=new["expiresAt"],
|
||||
source="claude-code-refreshed",
|
||||
)
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
# CRITICAL: without these headers Anthropic returns 500s on OAuth tokens.
|
||||
# Match Hermes' OAuth header set exactly. litellm auto-adds oauth-2025-04-20.
|
||||
return {
|
||||
"anthropic-beta": ",".join([
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
"claude-code-20250219",
|
||||
]),
|
||||
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("claude-code", lambda: ClaudeCodeAuth())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Codex / ChatGPT subscription — read ~/.codex/auth.json (or CODEX_HOME)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def _read_codex_tokens() -> Optional[Dict[str, Any]]:
|
||||
home = os.environ.get("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
||||
auth_path = Path(home).expanduser() / "auth.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
tokens = payload.get("tokens") or {}
|
||||
if not tokens.get("access_token") or not tokens.get("refresh_token"):
|
||||
return None
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_jwt_expiring(jwt: str, skew_seconds: int = 60) -> bool:
|
||||
"""Decode JWT exp claim without verifying signature."""
|
||||
import base64
|
||||
try:
|
||||
_hdr, payload_b64, _sig = jwt.split(".")
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))
|
||||
exp = int(payload.get("exp", 0))
|
||||
return exp and (time.time() + skew_seconds) >= exp
|
||||
except Exception:
|
||||
return False # treat unknown as not-expiring; refresh on actual 401
|
||||
|
||||
|
||||
class CodexAuth:
|
||||
name = "codex"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Codex uses Responses API, not Chat Completions
|
||||
# OpenAI chat-completions requests will 404 against this endpoint
|
||||
raise AuthError(
|
||||
"Codex auth is experimental and requires custom Responses API transport. "
|
||||
"Use a standard OpenAI API key or wait for proper Codex support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# OpenAI Codex tokens are refreshed by the Codex CLI itself; we rely
|
||||
# on the CLI being run periodically. A direct refresh would require
|
||||
# replicating their device-flow client — out of scope for v1.
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"openai-beta": "responses=v1",
|
||||
"user-agent": "codex-cli/0.1.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("codex", lambda: CodexAuth())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Gemini CLI OAuth — read ~/.gemini/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_GEMINI_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
|
||||
|
||||
|
||||
def _read_gemini_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Gemini CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class GeminiCliAuth:
|
||||
name = "gemini-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Gemini CLI uses Code Assist API, not OpenAI chat format
|
||||
# Standard OpenAI chat-completions requests will not work with cloudcode-pa endpoints
|
||||
raise AuthError(
|
||||
"Gemini CLI auth is experimental and requires custom Code Assist transport. "
|
||||
"Use standard Gemini API keys or wait for proper Code Assist support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Gemini CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "gemini-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("gemini-cli", lambda: GeminiCliAuth())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Subscription-auth protocol — what every provider must implement."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionCredentials:
|
||||
"""Resolved credentials ready to inject into an LLM client."""
|
||||
api_key: str # access_token (or "" for SDK-default chains like AWS)
|
||||
base_url: str = "" # provider endpoint
|
||||
headers: Dict[str, str] = field(default_factory=dict) # extra headers (UA, x-app, betas)
|
||||
auth_scheme: str = "bearer" # "bearer" | "x-api-key" | "sdk-default"
|
||||
expires_at_ms: Optional[int] = None # epoch ms; None = no expiry tracked
|
||||
source: str = "" # "claude-code-keychain", "codex-cli-file", etc.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SubscriptionAuthProtocol(Protocol):
|
||||
"""Discover, refresh, and inject subscription credentials."""
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
"""Return current valid credentials, refreshing if expiring soon.
|
||||
|
||||
Raises:
|
||||
AuthError: if no usable credentials can be resolved.
|
||||
"""
|
||||
...
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
"""Force a refresh and return the new credentials."""
|
||||
...
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
"""Provider-specific headers (UA, beta flags) keyed off endpoint/model."""
|
||||
...
|
||||
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
"""Raised when subscription credentials cannot be resolved."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Qwen CLI OAuth — read ~/.qwen/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
|
||||
|
||||
def _read_qwen_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Qwen CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".qwen" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class QwenCliAuth:
|
||||
name = "qwen-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
tokens = _read_qwen_tokens()
|
||||
if not tokens:
|
||||
raise AuthError(
|
||||
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
|
||||
"Install Qwen CLI and run 'qwen login'."
|
||||
)
|
||||
return SubscriptionCredentials(
|
||||
api_key=tokens["access_token"],
|
||||
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
|
||||
headers=self.headers_for(_QWEN_BASE_URL, ""),
|
||||
auth_scheme="bearer",
|
||||
source="qwen-cli-file",
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Qwen CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "qwen-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("qwen-cli", lambda: QwenCliAuth())
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Thread-safe provider registry for subscription auth."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
|
||||
_REGISTRY: Dict[str, Callable[[], SubscriptionAuthProtocol]] = {}
|
||||
_LOCK = threading.RLock()
|
||||
_BUILTIN_REGISTERED = False
|
||||
|
||||
|
||||
def register_subscription_provider(
|
||||
provider_id: str,
|
||||
factory: Callable[[], SubscriptionAuthProtocol],
|
||||
) -> None:
|
||||
"""Register a new subscription-auth provider.
|
||||
|
||||
Args:
|
||||
provider_id: short id e.g. "claude-code", "codex", "gemini-cli".
|
||||
factory: zero-arg callable returning a SubscriptionAuthProtocol impl.
|
||||
"""
|
||||
with _LOCK:
|
||||
_REGISTRY[provider_id] = factory
|
||||
|
||||
|
||||
def list_subscription_providers() -> List[str]:
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
def resolve_subscription_credentials(provider_id: str) -> SubscriptionCredentials:
|
||||
"""Look up provider by id and resolve its credentials."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
if factory is None:
|
||||
raise AuthError(
|
||||
f"Unknown subscription provider {provider_id!r}. "
|
||||
f"Registered: {list_subscription_providers()}"
|
||||
)
|
||||
return factory().resolve_credentials()
|
||||
|
||||
|
||||
def get_subscription_provider(provider_id: str) -> Optional[SubscriptionAuthProtocol]:
|
||||
"""Return a new provider instance for *provider_id*, or None if not registered."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
return factory() if factory is not None else None
|
||||
|
||||
|
||||
def _ensure_builtins() -> None:
|
||||
"""Lazy register built-in providers on first use."""
|
||||
global _BUILTIN_REGISTERED
|
||||
with _LOCK:
|
||||
if _BUILTIN_REGISTERED:
|
||||
return
|
||||
# Import modules inside the lock to prevent race conditions
|
||||
from . import claude_code, codex, gemini_cli, qwen_cli # noqa: F401
|
||||
# Each module registers itself at import time.
|
||||
_BUILTIN_REGISTERED = True
|
||||
@@ -42,6 +42,7 @@ 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
|
||||
from datetime import datetime, timezone
|
||||
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.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
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.utcnow().isoformat() + "Z"
|
||||
self._state.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
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.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
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
|
||||
from datetime import datetime, timezone
|
||||
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.utcnow().isoformat())
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).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.utcnow().isoformat()),
|
||||
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
class BaseLLMGrader:
|
||||
|
||||
@@ -71,7 +71,8 @@ class Mem0Adapter:
|
||||
from mem0 import Memory
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is not installed. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
if self._config:
|
||||
|
||||
@@ -361,6 +361,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
prompt_caching: Optional[bool] = None,
|
||||
claude_memory: Optional[Union[bool, Any]] = None,
|
||||
failover_manager: Optional[FailoverManagerProtocol] = None,
|
||||
auth: Optional[str] = None, # NEW: "claude-code", "codex", "gemini-cli", "qwen-cli"
|
||||
**extra_settings
|
||||
):
|
||||
# Configure logging only once at the class level
|
||||
@@ -437,6 +438,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Subscription auth
|
||||
self._auth_provider_id = auth
|
||||
self._cached_subscription_creds = None # SubscriptionCredentials | None
|
||||
|
||||
# Failover management
|
||||
self._failover_manager = failover_manager
|
||||
self._current_profile = None # Track current auth profile for failover
|
||||
@@ -715,6 +720,35 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
def _resolve_subscription_creds(self):
|
||||
"""Lazy resolve + cache subscription credentials, checking expiration."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
|
||||
# Check if cached credentials are expired
|
||||
if (self._cached_subscription_creds and
|
||||
self._cached_subscription_creds.expires_at_ms and
|
||||
self._cached_subscription_creds.expires_at_ms <= int(time.time() * 1000)):
|
||||
self._cached_subscription_creds = None
|
||||
|
||||
if self._cached_subscription_creds is None:
|
||||
from ..auth import resolve_subscription_credentials
|
||||
self._cached_subscription_creds = resolve_subscription_credentials(
|
||||
self._auth_provider_id,
|
||||
)
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _refresh_subscription_creds(self):
|
||||
"""Force refresh of subscription credentials."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
from ..auth.subscription.registry import get_subscription_provider
|
||||
provider = get_subscription_provider(self._auth_provider_id)
|
||||
if provider is None:
|
||||
return None
|
||||
self._cached_subscription_creds = provider.refresh()
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
|
||||
@@ -751,6 +785,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -840,6 +889,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -4593,6 +4657,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
filtered_extra_settings = {k: v for k, v in self.extra_settings.items() if k != 'metrics'}
|
||||
params.update(filtered_extra_settings)
|
||||
|
||||
# Inject subscription credentials if auth provider is set
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds:
|
||||
# Use litellm's native OAuth detection (auto-detects sk-ant-oat-* and switches to Bearer)
|
||||
params["api_key"] = creds.api_key
|
||||
if creds.base_url:
|
||||
params["base_url"] = creds.base_url
|
||||
if creds.headers:
|
||||
extra_headers = dict(params.get("extra_headers") or {})
|
||||
extra_headers.update(creds.headers)
|
||||
params["extra_headers"] = extra_headers
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
|
||||
@@ -907,6 +907,8 @@ 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
|
||||
@@ -1167,6 +1169,8 @@ 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
|
||||
@@ -1638,8 +1642,10 @@ 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)
|
||||
@@ -1835,10 +1841,12 @@ 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)
|
||||
@@ -2037,6 +2045,8 @@ 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:
|
||||
|
||||
@@ -40,7 +40,8 @@ def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
import mem0
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
return Mem0MemoryAdapter(mem0_config=kwargs)
|
||||
@@ -363,7 +364,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB short-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -371,7 +372,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
|
||||
@@ -395,7 +396,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB long-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -403,7 +404,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Decomposed memory functionality - imported as mixins for backward compatibility
|
||||
from .storage import StorageMixin
|
||||
@@ -143,6 +143,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
adapter = None
|
||||
provider_explicitly_requested = provider != "rag" # "rag" is default, others are explicit
|
||||
|
||||
try:
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = get_memory_adapter(adapter_name, **adapter_config)
|
||||
@@ -154,6 +157,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# If the provider was explicitly requested, try to give a helpful error message
|
||||
if provider_explicitly_requested:
|
||||
try:
|
||||
# Try the factory function directly to get the specific ImportError message
|
||||
from .adapters.factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
)
|
||||
factory_map = {
|
||||
"mem0": create_mem0_memory_adapter,
|
||||
"chroma": create_chroma_memory_adapter,
|
||||
"mongodb": create_mongodb_memory_adapter,
|
||||
}
|
||||
if adapter_name in factory_map:
|
||||
# Call the factory to get ImportError with installation hint
|
||||
# or capture successful adapter creation
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = factory_map[adapter_name](**adapter_config)
|
||||
except ImportError:
|
||||
# Re-raise ImportError with installation instructions
|
||||
raise
|
||||
# All other exceptions (e.g., ValueError for missing API key)
|
||||
# propagate naturally, preserving backward compatibility
|
||||
|
||||
# If factory succeeded, use that adapter
|
||||
if adapter is not None:
|
||||
self.memory_adapter = adapter
|
||||
self.provider = adapter_name
|
||||
return
|
||||
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Try each fallback preference individually
|
||||
@@ -658,7 +692,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
@@ -884,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
@@ -1407,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
|
||||
from datetime import datetime, timezone
|
||||
ident = str(time.time_ns())
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"user_id": user_id,
|
||||
"content": text,
|
||||
"metadata": meta,
|
||||
"created_at": datetime.utcnow()
|
||||
"created_at": datetime.now(timezone.utc)
|
||||
}
|
||||
self.mongo_users.insert_one(doc)
|
||||
self._log_verbose(f"Successfully stored user memory for {user_id}")
|
||||
|
||||
@@ -15,6 +15,8 @@ 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",
|
||||
@@ -22,5 +24,10 @@ __all__ = [
|
||||
"SandboxStatus",
|
||||
"ResourceLimits",
|
||||
"SandboxConfig",
|
||||
"SecurityPolicy",
|
||||
"SecurityPolicy",
|
||||
"SandboxManager",
|
||||
"SecurityWarning",
|
||||
"check_code_safety",
|
||||
"format_warnings",
|
||||
"get_security_summary",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@@ -90,7 +90,43 @@ def __getattr__(name: str):
|
||||
from .hierarchy import ExtendedSessionData
|
||||
_module_cache[name] = ExtendedSessionData
|
||||
return ExtendedSessionData
|
||||
|
||||
|
||||
# W1 — Identity resolver
|
||||
if name == "IdentityResolverProtocol":
|
||||
from .identity import IdentityResolverProtocol
|
||||
_module_cache[name] = IdentityResolverProtocol
|
||||
return IdentityResolverProtocol
|
||||
if name == "IdentityLink":
|
||||
from .identity import IdentityLink
|
||||
_module_cache[name] = IdentityLink
|
||||
return IdentityLink
|
||||
if name == "InMemoryIdentityResolver":
|
||||
from .identity import InMemoryIdentityResolver
|
||||
_module_cache[name] = InMemoryIdentityResolver
|
||||
return InMemoryIdentityResolver
|
||||
if name == "FileIdentityResolver":
|
||||
from .identity import FileIdentityResolver
|
||||
_module_cache[name] = FileIdentityResolver
|
||||
return FileIdentityResolver
|
||||
|
||||
# W1 — Task-local session context
|
||||
if name == "SessionContext":
|
||||
from .context import SessionContext
|
||||
_module_cache[name] = SessionContext
|
||||
return SessionContext
|
||||
if name == "set_session_context":
|
||||
from .context import set_session_context
|
||||
_module_cache[name] = set_session_context
|
||||
return set_session_context
|
||||
if name == "get_session_context":
|
||||
from .context import get_session_context
|
||||
_module_cache[name] = get_session_context
|
||||
return get_session_context
|
||||
if name == "clear_session_context":
|
||||
from .context import clear_session_context
|
||||
_module_cache[name] = clear_session_context
|
||||
return clear_session_context
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -105,4 +141,13 @@ __all__ = [
|
||||
"get_hierarchical_session_store",
|
||||
"SessionSnapshot",
|
||||
"ExtendedSessionData",
|
||||
# W1
|
||||
"IdentityResolverProtocol",
|
||||
"IdentityLink",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task-local session context for concurrent message handlers.
|
||||
|
||||
W1 — Replaces process-global ``os.environ`` based session metadata
|
||||
with ``contextvars.ContextVar`` so that two messages handled
|
||||
concurrently never overwrite each other's platform / chat / user IDs.
|
||||
|
||||
Each ``set_session_context`` call returns a token; pass it to
|
||||
``clear_session_context`` in a ``finally`` block to restore the
|
||||
previous state.
|
||||
|
||||
Usage in a bot handler::
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
set_session_context,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
)
|
||||
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
try:
|
||||
# ... agent handles message; tools can call get_session_context()
|
||||
ctx = get_session_context()
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Snapshot of the current message-handling context."""
|
||||
|
||||
platform: str = ""
|
||||
chat_id: str = ""
|
||||
chat_name: str = ""
|
||||
thread_id: str = ""
|
||||
user_id: str = ""
|
||||
user_name: str = ""
|
||||
unified_user_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SessionContext":
|
||||
return cls(
|
||||
platform=d.get("platform", ""),
|
||||
chat_id=d.get("chat_id", ""),
|
||||
chat_name=d.get("chat_name", ""),
|
||||
thread_id=d.get("thread_id", ""),
|
||||
user_id=d.get("user_id", ""),
|
||||
user_name=d.get("user_name", ""),
|
||||
unified_user_id=d.get("unified_user_id", ""),
|
||||
)
|
||||
|
||||
|
||||
_CTX: ContextVar[SessionContext] = ContextVar(
|
||||
"praisonai_session_context", default=SessionContext()
|
||||
)
|
||||
|
||||
|
||||
def set_session_context(
|
||||
platform: str = "",
|
||||
chat_id: str = "",
|
||||
chat_name: str = "",
|
||||
thread_id: str = "",
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
unified_user_id: str = "",
|
||||
) -> Token:
|
||||
"""Set the task-local session context. Returns a token for ``clear``."""
|
||||
ctx = SessionContext(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
unified_user_id=unified_user_id,
|
||||
)
|
||||
return _CTX.set(ctx)
|
||||
|
||||
|
||||
def get_session_context() -> SessionContext:
|
||||
"""Return the current task's session context (empty if unset)."""
|
||||
return _CTX.get()
|
||||
|
||||
|
||||
def clear_session_context(token: Token) -> None:
|
||||
"""Restore the previous session context using the token from ``set``."""
|
||||
try:
|
||||
_CTX.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
# Token from a different context; reset to empty
|
||||
_CTX.set(SessionContext())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Cross-platform identity resolution for agents.
|
||||
|
||||
W1 — Optional, opt-in mapping from ``(platform, platform_user_id)``
|
||||
to a single ``unified_user_id`` so the same human is recognised across
|
||||
Telegram, Discord, Slack, etc.
|
||||
|
||||
By default (no links registered) ``resolve()`` returns the
|
||||
platform-prefixed string unchanged — no surprises, no privacy leak.
|
||||
|
||||
Linking is performed explicitly, typically after a DM-based pairing
|
||||
flow has cryptographically verified ownership of both accounts.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("telegram", "12345", "alice")
|
||||
resolver.link("discord", "snowflake-1", "alice")
|
||||
assert resolver.resolve("telegram", "12345") == "alice"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityLink:
|
||||
"""Immutable mapping from a platform-scoped user ID to a unified ID."""
|
||||
|
||||
platform: str
|
||||
platform_user_id: str
|
||||
unified_user_id: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityResolverProtocol(Protocol):
|
||||
"""Maps platform-scoped user IDs to a unified identity.
|
||||
|
||||
Implementations MUST be thread-safe. Implementations SHOULD return
|
||||
``f"{platform}:{platform_user_id}"`` for unlinked users so callers
|
||||
never need to special-case the unlinked path.
|
||||
"""
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
"""Return ``unified_user_id`` for the given platform user.
|
||||
|
||||
If no link is registered, return ``f"{platform}:{platform_user_id}"``.
|
||||
"""
|
||||
...
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
"""Register a mapping. Overwrites any existing mapping for the same
|
||||
``(platform, platform_user_id)`` pair.
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
"""Remove a mapping. No-op when no link exists."""
|
||||
...
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
"""Return all links pointing to the given unified ID."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdentityResolver:
|
||||
"""Thread-safe in-memory ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Suitable for tests and single-process deployments. Production
|
||||
deployments with multiple processes should use a persistent
|
||||
implementation backed by SQLite, Redis, or a database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._links: dict[tuple[str, str], str] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
with self._lock:
|
||||
return self._links.get(
|
||||
(platform, platform_user_id),
|
||||
f"{platform}:{platform_user_id}",
|
||||
)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._links[(platform, platform_user_id)] = unified_user_id
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._links.pop((platform, platform_user_id), None)
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
with self._lock:
|
||||
return [
|
||||
IdentityLink(p, u, unified_user_id)
|
||||
for (p, u), uid in self._links.items()
|
||||
if uid == unified_user_id
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_IDENTITY_PATH = Path(
|
||||
os.environ.get(
|
||||
"PRAISONAI_IDENTITY_PATH",
|
||||
os.path.expanduser("~/.praisonai/identity.json"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FileIdentityResolver(InMemoryIdentityResolver):
|
||||
"""JSON-file-backed ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Loads links from disk on construction; persists on every mutation
|
||||
via atomic temp-file + ``os.replace``. Thread-safe.
|
||||
|
||||
Default path: ``$PRAISONAI_IDENTITY_PATH`` or ``~/.praisonai/identity.json``.
|
||||
|
||||
Storage format::
|
||||
|
||||
{
|
||||
"links": {
|
||||
"telegram::12345": "alice",
|
||||
"discord::snowflake-1": "alice"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Optional[Path | str] = None) -> None:
|
||||
super().__init__()
|
||||
self._path: Path = Path(path) if path else _DEFAULT_IDENTITY_PATH
|
||||
# Serialises concurrent disk writes so that two simultaneous
|
||||
# link()/unlink() calls cannot interleave their os.replace() and
|
||||
# lose the second writer's data.
|
||||
self._flush_lock = threading.Lock()
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(platform: str, platform_user_id: str) -> str:
|
||||
return f"{platform}::{platform_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _decode_key(encoded: str) -> tuple[str, str]:
|
||||
platform, _, user = encoded.partition("::")
|
||||
return platform, user
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if not self._path.exists():
|
||||
return
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
links = data.get("links", {}) if isinstance(data, dict) else {}
|
||||
with self._lock:
|
||||
self._links = {
|
||||
self._decode_key(k): v
|
||||
for k, v in links.items()
|
||||
if isinstance(v, str)
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("FileIdentityResolver: failed to load %s: %s", self._path, e)
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Hold _flush_lock for the entire write (snapshot + disk I/O) so
|
||||
# that concurrent calls always write the latest in-memory state and
|
||||
# the last os.replace() wins rather than an older snapshot.
|
||||
with self._flush_lock:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"links": {
|
||||
self._encode_key(p, u): uid
|
||||
for (p, u), uid in self._links.items()
|
||||
}
|
||||
}
|
||||
# Keep the _lock held across the entire write operation to prevent
|
||||
# TOCTOU race where a concurrent link() could modify _links between
|
||||
# payload snapshot and os.replace() completion.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self._path.parent), prefix=".identity-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
try:
|
||||
os.chmod(self._path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.warning("FileIdentityResolver: failed to flush %s: %s", self._path, e)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
super().link(platform, platform_user_id, unified_user_id)
|
||||
self._flush()
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
super().unlink(platform, platform_user_id)
|
||||
self._flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityLink",
|
||||
"IdentityResolverProtocol",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .discovery import discover_skills
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
from .prompt import generate_skills_xml
|
||||
from .substitution import render_skill_body
|
||||
@@ -271,8 +271,7 @@ class SkillManager:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Create skill directory
|
||||
from .discovery import get_default_skill_directories
|
||||
skill_dirs = get_default_skill_directories()
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
|
||||
import os
|
||||
|
||||
@@ -346,13 +346,15 @@ 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",
|
||||
}
|
||||
|
||||
@@ -390,7 +392,7 @@ def get_backend(
|
||||
Factory function to get a storage backend.
|
||||
|
||||
Args:
|
||||
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
|
||||
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
|
||||
**kwargs: Backend-specific arguments
|
||||
|
||||
Returns:
|
||||
@@ -431,9 +433,13 @@ 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")
|
||||
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""
|
||||
@@ -442,7 +448,8 @@ 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"}:
|
||||
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
|
||||
"ValkeyBackend", "ValkeySearchBackend"}:
|
||||
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, ToolRegistry
|
||||
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 .tools import Tools
|
||||
|
||||
# Export Injected type directly for easy access
|
||||
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
|
||||
|
||||
|
||||
# Python Tools
|
||||
'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'),
|
||||
'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),
|
||||
|
||||
|
||||
# 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', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', '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', 'ToolRegistry',
|
||||
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
|
||||
'Tools',
|
||||
# Validation and retry protocols
|
||||
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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,6 +268,11 @@ 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,6 +36,30 @@ 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.
|
||||
|
||||
@@ -48,23 +72,6 @@ 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)):
|
||||
@@ -72,28 +79,26 @@ def _validate_code_ast(code: str):
|
||||
|
||||
# Block attribute access to dangerous dunders
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in _blocked_attrs:
|
||||
if node.attr in _SANDBOX_BLOCKED_ATTRS:
|
||||
return (
|
||||
f"Access to attribute '{node.attr}' is restricted"
|
||||
)
|
||||
|
||||
# Block calls to dangerous builtins by name
|
||||
# Block calls to dangerous builtins (bare name or attribute access)
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in (
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir',
|
||||
):
|
||||
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
|
||||
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 _blocked_attrs):
|
||||
if any(attr in node.value for attr in _SANDBOX_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 _blocked_attrs):
|
||||
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
|
||||
return f"String constant contains restricted attribute name"
|
||||
|
||||
return None
|
||||
@@ -112,7 +117,16 @@ 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:
|
||||
@@ -151,21 +165,8 @@ def safe_execute():
|
||||
}}
|
||||
|
||||
# Block dangerous patterns
|
||||
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__',
|
||||
}}
|
||||
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
|
||||
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
@@ -182,14 +183,20 @@ def safe_execute():
|
||||
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
|
||||
"success": False
|
||||
}}
|
||||
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'):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in blocked_calls:
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
"stderr": f"Call to '{{node.func.id}}' is not allowed",
|
||||
"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",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
@@ -481,7 +488,7 @@ def _execute_code_direct(
|
||||
'__import__', 'import ', 'from ', 'exec', 'eval',
|
||||
'compile', 'open(', 'file(', 'input(', 'raw_input',
|
||||
'__subclasses__', '__bases__', '__globals__', '__code__',
|
||||
'__class__', 'globals(', 'locals(', 'vars('
|
||||
'__class__', '__self__', 'globals(', 'locals(', 'vars('
|
||||
]
|
||||
|
||||
code_lower = code.lower()
|
||||
|
||||
@@ -184,6 +184,41 @@ 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:
|
||||
@@ -381,3 +416,24 @@ 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,6 +11,8 @@ 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
|
||||
@@ -20,6 +22,48 @@ 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."""
|
||||
|
||||
@@ -59,31 +103,9 @@ class SpiderTools:
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
|
||||
# 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']:
|
||||
if _host_is_blocked(parsed.hostname):
|
||||
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:
|
||||
|
||||
@@ -45,6 +45,10 @@ __all__ = [
|
||||
# Redaction
|
||||
"redact_dict",
|
||||
"REDACT_KEYS",
|
||||
# C7 — PII redaction for LLM egress
|
||||
"scrub_pii_text",
|
||||
"enable_pii_redaction",
|
||||
"disable_pii_redaction",
|
||||
# Context events (for replay)
|
||||
"ContextEvent",
|
||||
"ContextEventType",
|
||||
@@ -79,6 +83,10 @@ def __getattr__(name: str):
|
||||
if name in ("redact_dict", "REDACT_KEYS"):
|
||||
from .redact import redact_dict, REDACT_KEYS
|
||||
return locals()[name]
|
||||
|
||||
if name in ("scrub_pii_text", "enable_pii_redaction", "disable_pii_redaction"):
|
||||
from .redact import scrub_pii_text, enable_pii_redaction, disable_pii_redaction
|
||||
return locals()[name]
|
||||
|
||||
# Context events (for replay)
|
||||
if name in ("ContextEvent", "ContextEventType"):
|
||||
|
||||
@@ -5,8 +5,9 @@ Provides deterministic redaction of sensitive data in tool arguments
|
||||
and results. Uses only stdlib for zero dependencies.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Set
|
||||
from typing import Any, Dict, Optional, Set
|
||||
import re
|
||||
import threading
|
||||
|
||||
# Keys that should always be redacted (case-insensitive matching)
|
||||
REDACT_KEYS: Set[str] = {
|
||||
@@ -119,6 +120,109 @@ def _redact_key_value(key: str, value: Any) -> Any:
|
||||
return _redact_value(value)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — PII redaction for LLM egress (opt-in BEFORE_LLM middleware)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Additional value-pattern rules for strings that look like secrets
|
||||
# even when no key=value pair surrounds them.
|
||||
_VALUE_PATTERNS = (
|
||||
# OpenAI-style API keys
|
||||
(re.compile(r"\bsk-[A-Za-z0-9]{12,}\b"), "[REDACTED]"),
|
||||
# US SSN
|
||||
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED-SSN]"),
|
||||
# Credit card — canonical 4-group format only (e.g. "4111 1111 1111 1111").
|
||||
# @claude: The bare \b\d{16}\b branch was removed — it matches ANY 16-digit sequence:
|
||||
# UNIX microsecond timestamps, distributed trace IDs, order/invoice numbers, phone
|
||||
# numbers — causing false-positive redactions in LLM tool outputs and RAG results.
|
||||
# Unformatted 16-digit strings are indistinguishable from non-card IDs at regex level.
|
||||
# The 4-group pattern covers real-world formatted card numbers with near-zero FP rate.
|
||||
(re.compile(r"\b(?:\d{4}[ -]){3}\d{4}\b"), "[REDACTED-CC]"),
|
||||
# Email (optional — often safe, but default-scrub for compliance)
|
||||
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[REDACTED-EMAIL]"),
|
||||
)
|
||||
|
||||
|
||||
def scrub_pii_text(text: str) -> str:
|
||||
"""Redact API keys, passwords, SSNs, credit cards, and emails from free text.
|
||||
|
||||
Reuses REDACT_KEYS for ``key=value`` pairs and adds value-pattern rules
|
||||
for naked secrets. Zero dependency (stdlib re only).
|
||||
|
||||
Args:
|
||||
text: Input string — returned unchanged if empty or None.
|
||||
|
||||
Returns:
|
||||
Scrubbed string with literal secrets replaced by ``[REDACTED*]``.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
result = redact_string(text, enabled=True)
|
||||
for pat, repl in _VALUE_PATTERNS:
|
||||
result = pat.sub(repl, result)
|
||||
return result
|
||||
|
||||
|
||||
# Module-level state for idempotent enable/disable
|
||||
_PII_HOOK_ID: Optional[str] = None
|
||||
_PII_HOOK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _pii_before_llm_hook(event_data):
|
||||
"""BEFORE_LLM hook that scrubs every message's content in-place."""
|
||||
try:
|
||||
messages = getattr(event_data, "messages", None) or []
|
||||
modified = []
|
||||
for m in messages:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
m = {**m, "content": scrub_pii_text(m["content"])}
|
||||
modified.append(m)
|
||||
# Import locally to avoid module-level hooks dep (keeps redact.py lightweight)
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow", modified_input={"messages": modified})
|
||||
except Exception:
|
||||
# Never block the LLM call on a scrubber bug
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow")
|
||||
|
||||
|
||||
def enable_pii_redaction() -> str:
|
||||
"""Register a BEFORE_LLM hook that scrubs secrets from every message.
|
||||
|
||||
Idempotent — calling twice leaves exactly one hook registered.
|
||||
|
||||
Returns:
|
||||
The hook id (useful for :func:`disable_pii_redaction`).
|
||||
"""
|
||||
global _PII_HOOK_ID
|
||||
with _PII_HOOK_LOCK:
|
||||
if _PII_HOOK_ID is not None:
|
||||
return _PII_HOOK_ID
|
||||
from ..hooks.registry import get_default_registry
|
||||
from ..hooks.types import HookEvent
|
||||
reg = get_default_registry()
|
||||
_PII_HOOK_ID = reg.register_function(
|
||||
event=HookEvent.BEFORE_LLM,
|
||||
func=_pii_before_llm_hook,
|
||||
name="praisonaiagents.pii_redactor",
|
||||
)
|
||||
return _PII_HOOK_ID
|
||||
|
||||
|
||||
def disable_pii_redaction() -> bool:
|
||||
"""Unregister the PII-redaction hook. No-op if never enabled."""
|
||||
global _PII_HOOK_ID
|
||||
if _PII_HOOK_ID is None:
|
||||
return False
|
||||
from ..hooks.registry import get_default_registry
|
||||
reg = get_default_registry()
|
||||
try:
|
||||
reg.unregister(_PII_HOOK_ID)
|
||||
finally:
|
||||
_PII_HOOK_ID = None
|
||||
return True
|
||||
|
||||
|
||||
def redact_string(text: str, enabled: bool = True) -> str:
|
||||
"""
|
||||
Redact potential secrets from a string.
|
||||
|
||||
@@ -1,126 +1,41 @@
|
||||
"""
|
||||
A2UI (Agent-to-User Interface) Protocol Integration for PraisonAI Agents
|
||||
A2UI integration via Google's a2ui-agent-sdk (optional).
|
||||
|
||||
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,
|
||||
)
|
||||
Declarative agent UI uses the official SDK — not reimplemented in PraisonAI core.
|
||||
Install: pip install praisonaiagents[a2ui]
|
||||
"""
|
||||
|
||||
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,
|
||||
from praisonaiagents.ui.a2ui.adapter import (
|
||||
create_a2ui_part,
|
||||
generate_a2ui_system_prompt,
|
||||
get_schema_manager,
|
||||
is_a2ui_part,
|
||||
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,
|
||||
parse_a2ui_response,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 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",
|
||||
"A2UI",
|
||||
"create_a2ui_part",
|
||||
"generate_a2ui_system_prompt",
|
||||
"get_schema_manager",
|
||||
"is_a2ui_part",
|
||||
"get_a2ui_agent_extension",
|
||||
"AgentExtension",
|
||||
# Surface builder
|
||||
"Surface",
|
||||
# Agent wrapper
|
||||
"A2UIAgent",
|
||||
# Templates
|
||||
"ChatTemplate",
|
||||
"ListTemplate",
|
||||
"FormTemplate",
|
||||
"DashboardTemplate",
|
||||
"parse_a2ui_response",
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -1,358 +0,0 @@
|
||||
"""
|
||||
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})
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -1,364 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,205 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,523 +0,0 @@
|
||||
"""
|
||||
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,6 +4,8 @@ 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
|
||||
@@ -256,6 +258,47 @@ 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,
|
||||
@@ -278,37 +321,63 @@ 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())
|
||||
|
||||
try:
|
||||
# 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:
|
||||
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")
|
||||
|
||||
# Emit text message events
|
||||
yield TextMessageStartEvent(message_id=message_id, role="assistant")
|
||||
|
||||
if response:
|
||||
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")
|
||||
yield TextMessageContentEvent(message_id=message_id, delta=str(response))
|
||||
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
|
||||
# Emit run finished
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
|
||||
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(
|
||||
@@ -331,8 +400,6 @@ async def async_stream_agents_response(
|
||||
Yields:
|
||||
AG-UI events
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Emit run started
|
||||
yield create_run_started_event(thread_id, run_id)
|
||||
|
||||
@@ -344,7 +411,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_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, agents.start, user_input)
|
||||
else:
|
||||
raise ValueError("Agents must have 'start' or 'astart' method")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.35"
|
||||
version = "1.6.43"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,7 +30,7 @@ mcp = [
|
||||
|
||||
memory = [
|
||||
"chromadb>=1.0.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
]
|
||||
|
||||
knowledge = [
|
||||
@@ -48,7 +48,7 @@ graph = [
|
||||
|
||||
# Add LLM dependencies
|
||||
llm = [
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
"pydantic>=2.10.0"
|
||||
]
|
||||
|
||||
@@ -101,6 +101,22 @@ 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]",
|
||||
@@ -112,8 +128,12 @@ all = [
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[sandbox]",
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]"
|
||||
"praisonaiagents[crawl]",
|
||||
"praisonaiagents[autonomy]",
|
||||
"praisonaiagents[os]",
|
||||
"praisonaiagents[a2ui]",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate subscription auth fixes without pytest dependency.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to Python path
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.getcwd()))
|
||||
|
||||
def test_imports():
|
||||
"""Test that imports work correctly."""
|
||||
print("Testing imports...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
print("✅ Protocol imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
print("✅ Claude Code auth imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.registry import resolve_subscription_credentials
|
||||
print("✅ Registry imports work")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Import test failed: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_codex_experimental():
|
||||
"""Test that Codex raises experimental error."""
|
||||
print("\nTesting Codex experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.codex import CodexAuth
|
||||
auth = CodexAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Codex should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Codex correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Codex raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Codex test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_gemini_experimental():
|
||||
"""Test that Gemini CLI raises experimental error."""
|
||||
print("\nTesting Gemini CLI experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.gemini_cli import GeminiCliAuth
|
||||
auth = GeminiCliAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Gemini CLI should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Gemini CLI correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Gemini CLI raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini CLI test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_header_simplification():
|
||||
"""Test that Claude Code headers are simplified."""
|
||||
print("\nTesting Claude Code header simplification...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
# Should only have interleaved-thinking, not oauth header (litellm handles that)
|
||||
if "anthropic-beta" in headers:
|
||||
beta_header = headers["anthropic-beta"]
|
||||
if beta_header == "interleaved-thinking-2025-05-14":
|
||||
print("✅ Claude Code headers correctly simplified")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Unexpected beta header value: {beta_header}")
|
||||
return False
|
||||
else:
|
||||
print("❌ anthropic-beta header missing")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Header test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_agent_construction():
|
||||
"""Test basic agent construction with auth parameter."""
|
||||
print("\nTesting Agent construction with auth...")
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# This should work (though credential resolution will fail without actual creds)
|
||||
agent = Agent(name="test", auth="claude-code")
|
||||
print("✅ Agent construction with auth parameter works")
|
||||
|
||||
# Test invalid provider
|
||||
try:
|
||||
agent = Agent(name="test", auth="invalid-provider")
|
||||
print("⚠️ Invalid provider didn't fail at construction (will fail at runtime)")
|
||||
except Exception:
|
||||
print("✅ Invalid provider handling works")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent construction test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all validation tests."""
|
||||
print("=== Validating Subscription Auth Fixes ===\n")
|
||||
|
||||
tests = [
|
||||
test_imports,
|
||||
test_codex_experimental,
|
||||
test_gemini_experimental,
|
||||
test_header_simplification,
|
||||
test_agent_construction,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
for test in tests:
|
||||
if test():
|
||||
passed += 1
|
||||
else:
|
||||
print()
|
||||
|
||||
print(f"\n=== Results: {passed}/{total} tests passed ===")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 All validation tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("💥 Some tests failed!")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1 +0,0 @@
|
||||
"""A2UI Tests Package."""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,225 +0,0 @@
|
||||
"""
|
||||
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]
|
||||
@@ -1,228 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,318 +0,0 @@
|
||||
"""
|
||||
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,6 +98,38 @@ 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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Integration tests for Agent with subscription auth."""
|
||||
import pytest
|
||||
|
||||
# Skip entire module when LLM dependencies (e.g. pydantic) are not installed.
|
||||
# CI runs without the [llm] extra, so these are skipped there.
|
||||
pytest.importorskip("pydantic", reason="requires praisonaiagents[llm]")
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subscription_auth():
|
||||
"""Mock subscription auth that returns test credentials."""
|
||||
def mock_resolve_credentials(provider_id):
|
||||
if provider_id == "claude-code":
|
||||
return SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-stub",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={
|
||||
"anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
"user-agent": "claude-cli/2.1.0 (external, cli)",
|
||||
"x-app": "cli",
|
||||
},
|
||||
auth_scheme="bearer",
|
||||
source="test-mock"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unknown provider: {provider_id}")
|
||||
|
||||
return mock_resolve_credentials
|
||||
|
||||
|
||||
def test_agent_accepts_auth_kwarg(mock_subscription_auth):
|
||||
"""Test that Agent accepts auth parameter and passes it to LLM."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
llm="anthropic/claude-3-haiku-20240307",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
assert hasattr(agent, 'llm_instance')
|
||||
assert agent.llm_instance._auth_provider_id == "claude-code"
|
||||
|
||||
|
||||
def test_llm_kwargs_carry_oauth_headers(mock_subscription_auth):
|
||||
"""Test that LLM completion params include OAuth headers."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
from praisonaiagents.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-haiku-20240307", auth="claude-code")
|
||||
params = llm._build_completion_params()
|
||||
|
||||
assert params["api_key"] == "sk-ant-oat-stub"
|
||||
assert params["base_url"] == "https://api.anthropic.com"
|
||||
assert params["extra_headers"]["x-app"] == "cli"
|
||||
assert params["extra_headers"]["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
|
||||
|
||||
def test_agent_without_auth_works_normally():
|
||||
"""Test that Agent without auth parameter works as before."""
|
||||
agent = Agent(name="test-agent", llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
if hasattr(agent, 'llm_instance'):
|
||||
assert agent.llm_instance._auth_provider_id is None
|
||||
assert agent.llm_instance._cached_subscription_creds is None
|
||||
@@ -0,0 +1,328 @@
|
||||
"""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,6 +6,7 @@ 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
|
||||
@@ -27,7 +28,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -562,6 +562,206 @@ 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
|
||||
# ===========================================================================
|
||||
@@ -673,7 +873,6 @@ 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",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Smoke test for subscription authentication - exercises real auth flows.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
pytest.skip("litellm not available", allow_module_level=True)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
def test_claude_code_auth_flow():
|
||||
"""Test that Claude Code auth properly injects OAuth credentials."""
|
||||
# Mock litellm completion to capture the actual params sent
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Hello! This is a test response."
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
|
||||
# Mock the credential resolution to return OAuth token
|
||||
mock_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-test-oauth-token", # OAuth token format
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={"user-agent": "claude-cli/2.1.0", "x-app": "cli"},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = mock_creds
|
||||
|
||||
# Create agent with Claude Code auth
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="You are a test assistant.",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# Make a real request
|
||||
response = agent.start("Say hello")
|
||||
|
||||
# Verify the call was made
|
||||
assert mock_completion.called
|
||||
call_args = mock_completion.call_args
|
||||
params = call_args.kwargs if call_args.kwargs else {}
|
||||
|
||||
# Verify OAuth token was passed as api_key (litellm will auto-detect and use Bearer)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
assert params.get("base_url") == "https://api.anthropic.com"
|
||||
|
||||
# Verify provider headers are included
|
||||
extra_headers = params.get("extra_headers", {})
|
||||
assert "user-agent" in extra_headers
|
||||
assert "x-app" in extra_headers
|
||||
|
||||
# Verify we got a response (just check that completion was called with right params)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
|
||||
|
||||
def test_auth_refresh_on_401():
|
||||
"""Test that auth errors trigger credential refresh and retry."""
|
||||
# First call: return 401, second call: success
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Success after refresh"
|
||||
|
||||
auth_error = Exception("AuthenticationError: Invalid API key")
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
# First call raises auth error, second succeeds
|
||||
mock_completion.side_effect = [auth_error, mock_response]
|
||||
|
||||
# Mock credential resolution and refresh
|
||||
initial_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-expired-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
refreshed_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-fresh-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-refreshed"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = initial_creds
|
||||
|
||||
with patch('praisonaiagents.auth.subscription.registry.get_subscription_provider') as mock_provider:
|
||||
mock_auth_provider = Mock()
|
||||
mock_auth_provider.refresh.return_value = refreshed_creds
|
||||
mock_provider.return_value = mock_auth_provider
|
||||
|
||||
# Mock error classification to detect auth error
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
with patch.object(LLM, '_classify_error_and_should_retry') as mock_classify:
|
||||
# First call: auth error, can retry
|
||||
# Second call: shouldn't be called since retry succeeds
|
||||
mock_classify.return_value = ("auth", True, 0.0)
|
||||
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="Test agent",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# This should trigger refresh on first 401 and succeed on retry
|
||||
response = agent.start("Test refresh flow")
|
||||
|
||||
# Verify refresh was called
|
||||
assert mock_auth_provider.refresh.called
|
||||
|
||||
# Verify we got success response
|
||||
assert "Success after refresh" in response
|
||||
|
||||
|
||||
def test_invalid_auth_provider():
|
||||
"""Test that invalid auth providers raise clear errors."""
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
auth="nonexistent-provider"
|
||||
)
|
||||
agent.start("This should fail")
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "unknown" in error_msg or "provider" in error_msg
|
||||
|
||||
|
||||
def test_codex_experimental_error():
|
||||
"""Test that Codex auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="codex")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_gemini_experimental_error():
|
||||
"""Test that Gemini CLI auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="gemini-cli")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Manual test runner for development
|
||||
test_claude_code_auth_flow()
|
||||
test_auth_refresh_on_401()
|
||||
test_invalid_auth_provider()
|
||||
test_codex_experimental_error()
|
||||
test_gemini_experimental_error()
|
||||
print("✅ All subscription auth smoke tests passed!")
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real agentic E2E — Phase A (C1/C2/C4/C7).
|
||||
|
||||
Exercises all four gap closures in a single live LLM run:
|
||||
|
||||
* C1 seed — request deterministic output
|
||||
* C2 cancel — pass a non-tripped InterruptController
|
||||
* C4 validator — install a passthrough ToolValidator (asserts it was invoked)
|
||||
* C7 PII — enable_pii_redaction() so secrets in the prompt are scrubbed
|
||||
|
||||
Run::
|
||||
|
||||
ANTHROPIC_API_KEY=... python tests/smoke_core_phase_a_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _pick_model() -> str:
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
return "anthropic/claude-haiku-4-5"
|
||||
if os.getenv("GOOGLE_API_KEY"):
|
||||
return "gemini/gemini-2.5-flash"
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
from praisonaiagents.tools.validators import ValidationResult
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
|
||||
model = _pick_model()
|
||||
print(f"Model: {model}\n")
|
||||
|
||||
# C7 — register the PII scrubber for the whole run
|
||||
enable_pii_redaction()
|
||||
|
||||
# A trivial tool the LLM may or may not call — mainly to exercise the
|
||||
# validator wiring. We track how often it is asked to validate.
|
||||
validated: list[str] = []
|
||||
|
||||
class LoggingValidator:
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
validated.append(tool_name)
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def echo(text: str) -> str:
|
||||
"""Echo a string back."""
|
||||
return f"echoed: {text}"
|
||||
|
||||
agent = Agent(
|
||||
name="PhaseATester",
|
||||
instructions=(
|
||||
"You are a concise assistant. Answer with one short sentence. "
|
||||
"Feel free to call the echo tool if asked."
|
||||
),
|
||||
llm={"model": model},
|
||||
tools=[echo],
|
||||
)
|
||||
agent._tool_validator = LoggingValidator() # C4 wiring
|
||||
|
||||
tok = InterruptController() # C2 — not set, must not affect anything
|
||||
|
||||
# Prompt contains a fake secret — C7 must scrub it before LLM egress.
|
||||
prompt = (
|
||||
"My api_key=sk-XYZSECRET12345 — please answer: what is 2 + 2? "
|
||||
"Reply with a single digit."
|
||||
)
|
||||
|
||||
print(f"[prompt raw] {prompt}")
|
||||
reply = agent.chat(prompt, seed=42, cancel_token=tok)
|
||||
print(f"[reply] {reply}\n")
|
||||
|
||||
# Validate outcomes
|
||||
ok = True
|
||||
|
||||
if not reply:
|
||||
print("FAIL: LLM returned empty reply")
|
||||
ok = False
|
||||
elif "4" not in reply:
|
||||
print("FAIL: reply does not contain expected '4'")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C1 — seed threaded and LLM returned a valid reply")
|
||||
|
||||
# Confirm the scrubber registered its hook (C7)
|
||||
# (chat_history stores the user's raw prompt by design — scrubbing happens
|
||||
# only on egress to the LLM, verified here via hook registry state.)
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
has_pii = any(
|
||||
(getattr(h, "name", "") or "").startswith("praisonaiagents.pii_redactor")
|
||||
for ev in getattr(reg, "_hooks", {}).values()
|
||||
for h in (ev if isinstance(ev, list) else [])
|
||||
)
|
||||
if has_pii:
|
||||
print("PASS C7 — PII redactor hook active during LLM call")
|
||||
else:
|
||||
print("FAIL C7 — PII redactor hook missing from registry")
|
||||
ok = False
|
||||
|
||||
# Cancel-token path did not trip (C2)
|
||||
if tok.is_set():
|
||||
print("FAIL C2 — cancel token unexpectedly set")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C2 — cancel_token honoured without tripping")
|
||||
|
||||
# Validator invocation (C4) is exercised only if LLM actually called echo;
|
||||
# don't fail the run if it chose not to call a tool.
|
||||
if validated:
|
||||
print(f"PASS C4 — validator invoked for: {validated}")
|
||||
else:
|
||||
print("SKIP C4 — LLM chose not to call a tool this run (non-fatal)")
|
||||
|
||||
disable_pii_redaction()
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Phase A gap-closure tests: C1 seed, C2 cancel_token, C4 tool validator, C7 PII.
|
||||
|
||||
These tests exercise the four gap closures identified in the core-features gap
|
||||
analysis. They target the existing infrastructure (InterruptController,
|
||||
ToolValidatorProtocol, trace/redact rules) which was defined but not wired into
|
||||
`Agent.chat()` / tool execution paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.validators import (
|
||||
ToolValidatorProtocol,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C1 — seed threaded through Agent.chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1SeedPassthrough:
|
||||
def test_chat_signature_accepts_seed(self):
|
||||
"""`Agent.chat` must accept a per-call `seed=` kwarg."""
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "seed" in sig.parameters, (
|
||||
"Agent.chat() must expose seed= to let callers request determinism"
|
||||
)
|
||||
|
||||
def test_seed_reaches_llm_build_params(self, monkeypatch):
|
||||
"""A `seed=42` passed to chat() must appear in the LLM kwargs."""
|
||||
captured = {}
|
||||
|
||||
# Use dict-form llm= so the custom-LLM path (with llm_instance) is taken.
|
||||
agent = Agent(name="C1", instructions="say hi", llm={"model": "gpt-4o-mini"})
|
||||
assert getattr(agent, "llm_instance", None) is not None, \
|
||||
"dict llm= must produce a materialised llm_instance"
|
||||
|
||||
# Short-circuit the LLM call so nothing hits the network
|
||||
monkeypatch.setattr(
|
||||
agent.llm_instance,
|
||||
"get_response",
|
||||
lambda **kw: (captured.update(kw) or "ok"),
|
||||
)
|
||||
|
||||
agent.chat("hello", seed=42)
|
||||
assert captured.get("seed") == 42, (
|
||||
f"seed=42 did not reach LLM call; kwargs were {list(captured)}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C2 — cancel_token wired into chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2CancelToken:
|
||||
def test_chat_signature_accepts_cancel_token(self):
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "cancel_token" in sig.parameters, (
|
||||
"Agent.chat() must expose cancel_token= for cooperative cancellation"
|
||||
)
|
||||
|
||||
def test_preset_cancelled_token_aborts_chat_before_llm(self, monkeypatch):
|
||||
"""If the cancel token is already set when chat() is called,
|
||||
the LLM is NEVER invoked and the call returns cleanly (None)."""
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
|
||||
agent = Agent(name="C2", instructions="irrelevant", llm={"model": "gpt-4o-mini"})
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def must_not_be_called(**kwargs):
|
||||
called["n"] += 1
|
||||
return "should not be reached"
|
||||
|
||||
monkeypatch.setattr(agent.llm_instance, "get_response", must_not_be_called)
|
||||
|
||||
tok = InterruptController()
|
||||
tok.request("user-cancel")
|
||||
|
||||
try:
|
||||
agent.chat("anything", cancel_token=tok)
|
||||
except InterruptedError:
|
||||
pass # acceptable outcome
|
||||
assert called["n"] == 0, (
|
||||
"LLM was invoked despite pre-set cancel_token — cancellation not wired"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C4 — ToolValidatorProtocol wired into tool execution
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _RejectingValidator:
|
||||
"""Minimal ToolValidatorProtocol impl that always rejects."""
|
||||
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
errors=[f"arg-validator rejected {tool_name}"],
|
||||
remediation="Fix your tool-call schema.",
|
||||
)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
|
||||
class TestC4ToolValidator:
|
||||
def test_validator_implements_protocol(self):
|
||||
"""Sanity: our fixture validator satisfies the protocol."""
|
||||
v = _RejectingValidator()
|
||||
assert isinstance(v, ToolValidatorProtocol)
|
||||
|
||||
def test_validator_rejects_before_tool_runs(self):
|
||||
"""When agent._tool_validator rejects, the tool function is NEVER called
|
||||
and the returned string contains the validator's error."""
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_tool(x: int) -> int:
|
||||
calls["n"] += 1
|
||||
return x * 2
|
||||
|
||||
agent = Agent(name="C4", instructions="…", tools=[fake_tool], llm="gpt-4o-mini")
|
||||
agent._tool_validator = _RejectingValidator()
|
||||
|
||||
result = agent.execute_tool("fake_tool", {"x": 5})
|
||||
|
||||
assert calls["n"] == 0, "tool function was executed despite validator rejection"
|
||||
assert "rejected" in str(result).lower(), (
|
||||
f"validator error not surfaced: {result!r}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — enable_pii_redaction() registers a BEFORE_LLM middleware
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7PIIRedaction:
|
||||
def test_enable_pii_redaction_is_importable(self):
|
||||
from praisonaiagents.trace.redact import enable_pii_redaction
|
||||
assert callable(enable_pii_redaction)
|
||||
|
||||
def test_enable_pii_redaction_is_idempotent(self):
|
||||
"""Calling enable_pii_redaction() twice must not double-register."""
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
disable_pii_redaction() # ensure clean baseline
|
||||
enable_pii_redaction()
|
||||
first_state = _pii_state_snapshot()
|
||||
enable_pii_redaction()
|
||||
second_state = _pii_state_snapshot()
|
||||
assert first_state == second_state, "enable_pii_redaction is not idempotent"
|
||||
disable_pii_redaction()
|
||||
|
||||
def test_pii_scrubber_scrubs_api_keys(self):
|
||||
"""The scrubber function must redact API-key-shaped strings."""
|
||||
from praisonaiagents.trace.redact import scrub_pii_text
|
||||
|
||||
raw = "my api_key=sk-ABCDEF12345 and password=hunter2"
|
||||
scrubbed = scrub_pii_text(raw)
|
||||
assert "sk-ABCDEF12345" not in scrubbed
|
||||
assert "hunter2" not in scrubbed
|
||||
assert "[REDACTED]" in scrubbed
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pii_state_snapshot():
|
||||
"""Snapshot of hook registry state used by C7 idempotency test."""
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
# Count hooks registered for BEFORE_LLM — we just want stable identity
|
||||
hooks = getattr(reg, "_hooks", None) or getattr(reg, "hooks", None) or {}
|
||||
if isinstance(hooks, dict):
|
||||
bl = hooks.get("before_llm") or hooks.get("BEFORE_LLM") or []
|
||||
return len(bl)
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Regression tests for GHSA-gmjg-hv98-qggq:
|
||||
Undeclared __main__ callables must NOT be executed via execute_tool.
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
|
||||
|
||||
class _HookRunner:
|
||||
def execute_sync(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
def is_blocked(self, results):
|
||||
return False
|
||||
|
||||
|
||||
class _DummyAgent(ToolExecutionMixin):
|
||||
def __init__(self, tools=None):
|
||||
self.name = "test_agent"
|
||||
self.tools = tools or []
|
||||
self.chat_history = []
|
||||
self._hook_runner = _HookRunner()
|
||||
self.context_manager = None
|
||||
self._doom_loop_tracker = None
|
||||
self._perm_deny = frozenset()
|
||||
self._perm_allow = None
|
||||
self._approval_backend = None
|
||||
|
||||
|
||||
def _make_approved_registry():
|
||||
reg = MagicMock()
|
||||
reg.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
reg.mark_approved = MagicMock()
|
||||
return reg
|
||||
|
||||
|
||||
def _sneaky_undeclared(msg="pwned"):
|
||||
"""Callable present only in __main__, never declared as a tool."""
|
||||
return {"ran": msg}
|
||||
|
||||
|
||||
def test_undeclared_main_callable_not_executed():
|
||||
"""An undeclared callable in __main__ must NOT be invoked."""
|
||||
# Inject the function into __main__ to simulate the exploit scenario.
|
||||
original = getattr(sys.modules["__main__"], "_sneaky_undeclared", None)
|
||||
sys.modules["__main__"]._sneaky_undeclared = _sneaky_undeclared
|
||||
try:
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("_sneaky_undeclared", {"msg": "hello"})
|
||||
# Should NOT have run the function — result must indicate not-found, not success.
|
||||
assert result != {"ran": "hello"}, (
|
||||
"Undeclared __main__ callable was executed — vulnerability still present"
|
||||
)
|
||||
finally:
|
||||
if original is None:
|
||||
delattr(sys.modules["__main__"], "_sneaky_undeclared")
|
||||
else:
|
||||
sys.modules["__main__"]._sneaky_undeclared = original
|
||||
|
||||
|
||||
def test_declared_tool_still_executes():
|
||||
"""A properly declared tool must still be callable after the fix."""
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[my_tool])
|
||||
result = agent.execute_tool("my_tool", {"x": 3})
|
||||
assert result == 6, f"Declared tool did not execute correctly: {result}"
|
||||
|
||||
|
||||
def test_globals_callable_not_executed():
|
||||
"""A callable only in tool_execution module globals must NOT be invoked."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
# 'logging' is a module-level name in tool_execution; it must not be invocable.
|
||||
result = agent.execute_tool("logging", {})
|
||||
assert not isinstance(result, dict) or result.get("ran") is None, (
|
||||
"globals() fallback executed an unintended name"
|
||||
)
|
||||
|
||||
|
||||
def test_undeclared_returns_error_or_none():
|
||||
"""execute_tool with an unknown name should return None or an error dict, not raise."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("completely_nonexistent_xyz", {})
|
||||
# Result should be None or an error dict — not a successful execution.
|
||||
assert result is None or isinstance(result, dict), (
|
||||
f"Unexpected return type: {type(result)}"
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
assert result.get("ran") is None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for Claude Code subscription auth."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, mock_open
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import (
|
||||
ClaudeCodeAuth,
|
||||
_read_keychain_credentials,
|
||||
_read_file_credentials,
|
||||
_is_expiring,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_keychain_path_returns_none_off_darwin():
|
||||
"""Test keychain reading returns None on non-macOS platforms."""
|
||||
with patch.object(sys, 'platform', 'linux'):
|
||||
assert _read_keychain_credentials() is None
|
||||
|
||||
|
||||
def test_file_path_reads_credentials(monkeypatch):
|
||||
"""Test file credential reading."""
|
||||
# Create a temporary file with Claude Code credentials
|
||||
credentials_data = {
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat-test",
|
||||
"refreshToken": "rt-test",
|
||||
"expiresAt": 9999999999000,
|
||||
}
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
||||
json.dump(credentials_data, tmp)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with patch('praisonaiagents.auth.subscription.claude_code.Path') as mock_path:
|
||||
mock_home_path = mock_path.home.return_value
|
||||
mock_claude_path = mock_home_path / ".claude" / ".credentials.json"
|
||||
mock_claude_path.exists.return_value = True
|
||||
mock_claude_path.read_text.return_value = json.dumps(credentials_data)
|
||||
|
||||
creds = _read_file_credentials()
|
||||
assert creds["accessToken"] == "sk-ant-oat-test"
|
||||
assert creds["refreshToken"] == "rt-test"
|
||||
assert creds["source"] == "claude-code-file"
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_is_expiring():
|
||||
"""Test token expiry detection."""
|
||||
import time
|
||||
current_ms = int(time.time() * 1000)
|
||||
|
||||
# Token expires in 30 seconds (should be considered expiring with default 60s skew)
|
||||
soon_expired = current_ms + 30_000
|
||||
assert _is_expiring(soon_expired) is True
|
||||
|
||||
# Token expires in 2 minutes (should not be expiring)
|
||||
not_expired = current_ms + 120_000
|
||||
assert _is_expiring(not_expired) is False
|
||||
|
||||
# No expiry time
|
||||
assert _is_expiring(0) is False
|
||||
assert _is_expiring(None) is False
|
||||
|
||||
|
||||
def test_resolve_credentials_uses_env_first(monkeypatch):
|
||||
"""Test that environment variables take precedence."""
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat-from-env")
|
||||
|
||||
auth = ClaudeCodeAuth()
|
||||
creds = auth.resolve_credentials()
|
||||
|
||||
assert creds.api_key == "sk-ant-oat-from-env"
|
||||
assert creds.base_url == "https://api.anthropic.com"
|
||||
assert creds.headers["x-app"] == "cli"
|
||||
assert creds.headers["user-agent"].startswith("claude-cli/")
|
||||
assert creds.source == "env:ANTHROPIC_TOKEN"
|
||||
|
||||
|
||||
def test_resolve_credentials_no_auth_raises_error():
|
||||
"""Test that missing credentials raises AuthError."""
|
||||
auth = ClaudeCodeAuth()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_keychain_credentials', return_value=None):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_file_credentials', return_value=None):
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
auth.resolve_credentials()
|
||||
|
||||
assert "No Claude Code credentials found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_headers_for_includes_required_oauth_headers():
|
||||
"""Test that OAuth-specific headers are included."""
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
assert headers["anthropic-beta"] == "oauth-2025-04-20,interleaved-thinking-2025-05-14"
|
||||
assert headers["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
assert headers["x-app"] == "cli"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for subscription auth protocols."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import (
|
||||
SubscriptionCredentials,
|
||||
SubscriptionAuthProtocol,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_dataclass_defaults():
|
||||
"""Test SubscriptionCredentials dataclass defaults."""
|
||||
c = SubscriptionCredentials(api_key="test-key")
|
||||
assert c.api_key == "test-key"
|
||||
assert c.base_url == ""
|
||||
assert c.headers == {}
|
||||
assert c.auth_scheme == "bearer"
|
||||
assert c.expires_at_ms is None
|
||||
assert c.source == ""
|
||||
|
||||
|
||||
def test_protocol_runtime_check():
|
||||
"""Test that classes implementing the protocol are recognized."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test")
|
||||
def refresh(self):
|
||||
return SubscriptionCredentials(api_key="refreshed")
|
||||
def headers_for(self, base_url, model):
|
||||
return {"x-test": "header"}
|
||||
|
||||
assert isinstance(MockAuth(), SubscriptionAuthProtocol)
|
||||
|
||||
|
||||
def test_auth_error():
|
||||
"""Test AuthError exception."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
raise AuthError("test error")
|
||||
assert str(exc_info.value) == "test error"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for subscription auth registry."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
from praisonaiagents.auth.subscription import registry as _reg
|
||||
from praisonaiagents.auth.subscription.registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry():
|
||||
"""Clean the registry before and after each test, resetting the builtin flag.
|
||||
|
||||
Direct access to _REGISTRY and _BUILTIN_REGISTERED is intentional here:
|
||||
we are testing the registry implementation itself and need full state control
|
||||
for test isolation.
|
||||
"""
|
||||
original_registry = _REGISTRY.copy()
|
||||
original_builtin_flag = _reg._BUILTIN_REGISTERED
|
||||
_REGISTRY.clear()
|
||||
_reg._BUILTIN_REGISTERED = False
|
||||
yield
|
||||
_REGISTRY.clear()
|
||||
_REGISTRY.update(original_registry)
|
||||
_reg._BUILTIN_REGISTERED = original_builtin_flag
|
||||
|
||||
|
||||
def test_register_and_resolve():
|
||||
"""Test registering and resolving a provider."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test-key", source="mock")
|
||||
def refresh(self):
|
||||
return self.resolve_credentials()
|
||||
def headers_for(self, *_):
|
||||
return {}
|
||||
|
||||
register_subscription_provider("test-mock", lambda: MockAuth())
|
||||
assert "test-mock" in list_subscription_providers()
|
||||
|
||||
creds = resolve_subscription_credentials("test-mock")
|
||||
assert creds.api_key == "test-key"
|
||||
assert creds.source == "mock"
|
||||
|
||||
|
||||
def test_resolve_unknown_provider():
|
||||
"""Test resolving an unknown provider raises AuthError."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_subscription_credentials("unknown-provider")
|
||||
|
||||
assert "Unknown subscription provider 'unknown-provider'" in str(exc_info.value)
|
||||
@@ -48,6 +48,12 @@ 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."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Sandbox tests package
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
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}"
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tests for IdentityResolverProtocol and InMemoryIdentityResolver.
|
||||
|
||||
W1 — Cross-platform identity linking. An IdentityResolver maps
|
||||
``(platform, platform_user_id)`` → ``unified_user_id`` so that the same
|
||||
human can be recognised across Telegram, Discord, Slack, etc.
|
||||
|
||||
Linking is OPT-IN. By default the resolver returns the platform-prefixed
|
||||
ID unchanged (no surprises, no privacy leak).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.identity import (
|
||||
IdentityLink,
|
||||
IdentityResolverProtocol,
|
||||
InMemoryIdentityResolver,
|
||||
)
|
||||
|
||||
|
||||
class TestIdentityLink:
|
||||
def test_link_holds_platform_user_unified(self):
|
||||
link = IdentityLink(
|
||||
platform="telegram",
|
||||
platform_user_id="12345",
|
||||
unified_user_id="alice",
|
||||
)
|
||||
assert link.platform == "telegram"
|
||||
assert link.platform_user_id == "12345"
|
||||
assert link.unified_user_id == "alice"
|
||||
|
||||
def test_link_is_immutable(self):
|
||||
link = IdentityLink("telegram", "12345", "alice")
|
||||
with pytest.raises((AttributeError, Exception)):
|
||||
link.unified_user_id = "bob" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
def test_in_memory_satisfies_protocol(self):
|
||||
resolver = InMemoryIdentityResolver()
|
||||
assert isinstance(resolver, IdentityResolverProtocol)
|
||||
|
||||
def test_protocol_has_required_methods(self):
|
||||
proto_methods = {"resolve", "link", "unlink", "links_for"}
|
||||
assert proto_methods.issubset(set(dir(IdentityResolverProtocol)))
|
||||
|
||||
|
||||
class TestInMemoryResolverDefaults:
|
||||
def test_unlinked_returns_platform_prefixed_id(self):
|
||||
"""When no link exists, return ``{platform}:{user_id}`` unchanged."""
|
||||
r = InMemoryIdentityResolver()
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_unlinked_does_not_create_link(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.resolve("telegram", "12345")
|
||||
assert r.links_for("telegram:12345") == []
|
||||
|
||||
|
||||
class TestLinking:
|
||||
def test_link_two_platforms_resolves_to_same_unified(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
assert r.resolve("telegram", "12345") == "alice"
|
||||
assert r.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_reverts_to_default(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.unlink("telegram", "12345")
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_links_for_returns_all_platforms(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
links = r.links_for("alice")
|
||||
assert len(links) == 2
|
||||
platforms = {link.platform for link in links}
|
||||
assert platforms == {"telegram", "discord"}
|
||||
|
||||
def test_link_overwrites_previous(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("telegram", "12345", "bob")
|
||||
assert r.resolve("telegram", "12345") == "bob"
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_concurrent_links_dont_corrupt_state(self):
|
||||
import threading
|
||||
|
||||
r = InMemoryIdentityResolver()
|
||||
|
||||
def worker(i):
|
||||
r.link("telegram", f"u{i}", f"unified-{i}")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(50)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
for i in range(50):
|
||||
assert r.resolve("telegram", f"u{i}") == f"unified-{i}"
|
||||
|
||||
|
||||
class TestFileIdentityResolver:
|
||||
def test_persists_across_instances(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.link("discord", "snowflake-1", "alice")
|
||||
|
||||
# New instance, same file
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "alice"
|
||||
assert r2.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_persists(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.unlink("telegram", "12345")
|
||||
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_corrupt_file_does_not_crash(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
r = FileIdentityResolver(path=path)
|
||||
# Falls back to empty state
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_satisfies_protocol(self, tmp_path):
|
||||
from praisonaiagents.session.identity import (
|
||||
FileIdentityResolver,
|
||||
IdentityResolverProtocol,
|
||||
)
|
||||
r = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
assert isinstance(r, IdentityResolverProtocol)
|
||||
|
||||
def test_file_permissions_restricted(self, tmp_path):
|
||||
import sys
|
||||
if sys.platform.startswith("win"):
|
||||
pytest.skip("chmod semantics differ on Windows")
|
||||
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r = FileIdentityResolver(path=path)
|
||||
r.link("telegram", "12345", "alice")
|
||||
# 0o600 = owner-only read/write
|
||||
mode = path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for task-local SessionContextVars.
|
||||
|
||||
W1 — Concurrent message handlers must NOT clobber each other's
|
||||
session metadata. Use ``contextvars.ContextVar`` so each asyncio task
|
||||
has its own copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
SessionContext,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
set_session_context,
|
||||
)
|
||||
|
||||
|
||||
class TestSetGet:
|
||||
def test_set_and_get_roundtrips(self):
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
)
|
||||
try:
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == "telegram"
|
||||
assert ctx.chat_id == "100"
|
||||
assert ctx.user_id == "alice"
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
def test_default_context_is_empty(self):
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == ""
|
||||
assert ctx.chat_id == ""
|
||||
assert ctx.user_id == ""
|
||||
|
||||
def test_clear_restores_previous(self):
|
||||
token = set_session_context(platform="telegram", user_id="alice")
|
||||
clear_session_context(token)
|
||||
assert get_session_context().platform == ""
|
||||
|
||||
|
||||
class TestAsyncTaskIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_tasks_dont_clobber(self):
|
||||
seen = []
|
||||
|
||||
async def handler(name, delay):
|
||||
token = set_session_context(platform=name, user_id=name)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
seen.append(get_session_context().platform)
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
await asyncio.gather(
|
||||
handler("telegram", 0.02),
|
||||
handler("discord", 0.01),
|
||||
handler("slack", 0.03),
|
||||
)
|
||||
assert sorted(seen) == ["discord", "slack", "telegram"]
|
||||
|
||||
|
||||
class TestSessionContextDataclass:
|
||||
def test_context_has_all_fields(self):
|
||||
ctx = SessionContext(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
chat_name="DM",
|
||||
thread_id="t1",
|
||||
user_id="alice",
|
||||
user_name="Alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
assert ctx.unified_user_id == "alice-global"
|
||||
|
||||
def test_context_dict_roundtrip(self):
|
||||
ctx = SessionContext(platform="telegram", user_id="alice")
|
||||
d = ctx.to_dict()
|
||||
ctx2 = SessionContext.from_dict(d)
|
||||
assert ctx2.platform == "telegram"
|
||||
assert ctx2.user_id == "alice"
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user