mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc4058b17 |
No files matched your search
@@ -0,0 +1,330 @@
|
||||
<!-- wp:paragraph -->
|
||||
<p>praisonai-mcp is Tier 2e — the heavy MCP server host: full <code>MCPServer</code>, tool/resource/prompt registries, recipe bridge, HTTP-stream auth, and MCP client config management. Depends only on <code>praisonaiagents</code>. v0.0.2. Protocol: MCP 2025-11-25.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:merpress/mermaidjs {"align":"full"} -->
|
||||
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid alignfull"><pre class="mermaid">%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "lineColor": "#000000"}}}%%
|
||||
flowchart TB
|
||||
CLIENT[Cursor Claude Desktop]
|
||||
subgraph T1 [praisonaiagents]
|
||||
SDK[MCP client ToolsMCPServer]
|
||||
SEC[mcp_security shared]
|
||||
end
|
||||
subgraph T2e [praisonai-mcp]
|
||||
HOST[MCPServer]
|
||||
REG[tool resource prompt registry]
|
||||
TR[stdio http-stream]
|
||||
AUTH[api-key oauth scopes]
|
||||
CFG[mcp list add sync]
|
||||
end
|
||||
subgraph T2a [praisonai-code optional]
|
||||
LIGHT[serve mcp light]
|
||||
BRIDGE[_mcp_bridge]
|
||||
end
|
||||
subgraph T3 [praisonai wrapper optional]
|
||||
CAP[capabilities recipe]
|
||||
end
|
||||
CLIENT --> TR
|
||||
TR --> HOST
|
||||
HOST --> REG
|
||||
REG -.->|adapters| CAP
|
||||
CFG -.->|_code_bridge| LIGHT
|
||||
BRIDGE --> CFG
|
||||
SDK --> LIGHT
|
||||
SEC --> TR
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
class CLIENT agent
|
||||
class SDK agent
|
||||
class HOST agent
|
||||
class REG tool
|
||||
class TR tool
|
||||
class AUTH tool
|
||||
class CFG tool
|
||||
class LIGHT tool
|
||||
class SEC tool
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Three MCP layers (do not conflate)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Layer</th><th>Package</th><th>Role</th><th>Entry</th></tr></thead><tbody><tr><td>Client</td><td><code>praisonaiagents/mcp/</code></td><td>Connect agents to external MCP servers; OAuth storage</td><td><code>pip install "praisonaiagents[mcp]"</code></td></tr><tr><td>Light server</td><td><code>praisonai-code</code></td><td>Config-driven basic tool hosting</td><td><code>praisonai serve mcp</code></td></tr><tr><td>Heavy host</td><td><code>praisonai-mcp</code></td><td>Full server, adapters, recipe bridge, HTTP auth</td><td><code>praisonai mcp serve</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Install matrix</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Install</th><th><code>praisonai mcp</code></th><th><code>praisonai serve mcp</code></th><th><code>praisonai-mcp</code> script</th></tr></thead><tbody><tr><td><code>pip install praisonai-mcp</code></td><td>—</td><td>—</td><td>✅</td></tr><tr><td><code>pip install "praisonai-mcp[all]"</code></td><td>—</td><td>—</td><td>✅ HTTP + auth</td></tr><tr><td><code>pip install praisonai-code</code> only</td><td>hidden</td><td>✅ light</td><td>—</td></tr><tr><td><code>pip install "praisonai[mcp]"</code></td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td><code>pip install praisonai</code></td><td>✅</td><td>✅</td><td>✅</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>1. Standalone heavy host</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai-mcp[all]"
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
praisonai-mcp --version
|
||||
praisonai-mcp serve --transport stdio
|
||||
praisonai-mcp list-tools
|
||||
praisonai-mcp doctor</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>2. Full stack (same commands via <code>praisonai mcp …</code>)</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install praisonai
|
||||
# or MCP-only extra
|
||||
pip install "praisonai[mcp]"
|
||||
|
||||
praisonai mcp serve --transport stdio
|
||||
praisonai mcp list-tools
|
||||
praisonai mcp doctor</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>3. Light server only (no praisonai-mcp required)</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install praisonai-code "praisonaiagents[mcp]"
|
||||
|
||||
praisonai serve mcp --port 8080
|
||||
# Uses agents ToolsMCPServer — not the heavy MCPServer</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:separator -->
|
||||
<hr class="wp-block-separator has-alpha-channel-opacity"/>
|
||||
<!-- /wp:separator -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 1 — STDIO host (Cursor / Claude Desktop)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Heavy host — full PraisonAI tool registry
|
||||
praisonai-mcp serve --transport stdio
|
||||
# or
|
||||
praisonai mcp serve --transport stdio
|
||||
|
||||
# Generate client config
|
||||
praisonai mcp config-generate --client cursor --transport stdio
|
||||
praisonai mcp config-generate --client claude-desktop --transport stdio</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>STDIO uses JSON-RPC over stdin/stdout. Logs go to stderr only — never corrupt the protocol stream.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 2 — HTTP Stream host</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Streamable HTTP (MCP 2025-11-25) — not legacy standalone SSE. Requires <code>[server]</code> extra (starlette + uvicorn).</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonai mcp serve \
|
||||
--transport http-stream \
|
||||
--host 127.0.0.1 \
|
||||
--port 8080 \
|
||||
--endpoint /mcp \
|
||||
--api-key my-secret-key \
|
||||
--response-mode batch
|
||||
|
||||
# Endpoints: POST/GET/DELETE /mcp, plus /health</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Non-localhost binds require <code>--api-key</code> or <code>--keys-file</code>. Origin validation uses shared helpers from <code>praisonaiagents.mcp.mcp_security</code>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 3 — Recipe → MCP bridge</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Turn any PraisonAI recipe into a scoped MCP server with allow/deny lists and safe mode.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonai mcp serve-recipe support-reply --transport stdio
|
||||
|
||||
praisonai mcp validate-recipe support-reply
|
||||
praisonai mcp inspect-recipe support-reply
|
||||
|
||||
praisonai mcp config-generate-recipe support-reply --client claude-desktop</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Default denied tools include <code>shell.exec</code>, <code>file.delete</code>, <code>eval</code>, and similar destructive operations.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 4 — MCP client config management</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Config subcommands (<code>list</code>, <code>add</code>, <code>remove</code>, <code>sync</code>, <code>test</code>) require <code>praisonai-code</code> via <code>_code_bridge</code>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonai mcp list
|
||||
praisonai mcp add filesystem \
|
||||
--command npx \
|
||||
--args "-y @modelcontextprotocol/server-filesystem ."
|
||||
|
||||
praisonai mcp test filesystem
|
||||
praisonai mcp sync
|
||||
praisonai mcp tools
|
||||
praisonai mcp status</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 5 — Built-in registry (tools, resources, prompts)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Type</th><th>Examples</th><th>Adapter</th></tr></thead><tbody><tr><td>Tools</td><td><code>praisonai.chat.*</code>, <code>praisonai.workflow.*</code>, agent run helpers</td><td><code>mcp_server/adapters/capabilities.py</code>, <code>cli_tools.py</code></td></tr><tr><td>Resources</td><td><code>praisonai://memory/sessions</code>, <code>praisonai://workflows</code>, <code>praisonai://agents</code></td><td><code>adapters/resources.py</code></td></tr><tr><td>Prompts</td><td><code>deep-research</code>, <code>code-review</code>, <code>workflow-auto</code></td><td><code>adapters/prompts.py</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Inspect what the host exposes
|
||||
praisonai-mcp list-tools
|
||||
praisonai-mcp list-resources
|
||||
praisonai-mcp list-prompts
|
||||
|
||||
# Host tool search (MCPServerCLI)
|
||||
praisonai-mcp tools search "workflow"</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 6 — Security & auth</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Concern</th><th>Package</th><th>Module</th></tr></thead><tbody><tr><td>Origin validation, session IDs</td><td><code>praisonaiagents</code></td><td><code>mcp/mcp_security.py</code></td></tr><tr><td>API keys, scoped keys file</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/api_key.py</code></td></tr><tr><td>OAuth 2.1 + PKCE</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/oauth.py</code></td></tr><tr><td>OIDC discovery</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/oidc.py</code></td></tr><tr><td>Scope enforcement</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/scopes.py</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Scoped keys file (--keys-file)
|
||||
[
|
||||
{"key": "monitor-key", "name": "monitor", "scopes": ["tools:read", "resources:read"]},
|
||||
{"key": "admin-key", "name": "admin", "scopes": ["*"]}
|
||||
]
|
||||
|
||||
# Env vars
|
||||
export MCP_API_KEY=your-key
|
||||
export PRAISONAI_MCP_MAX_SESSIONS=1000</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 7 — CLI routing (lazy load)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Registry</th><th>Commands</th><th>Gate</th></tr></thead><tbody><tr><td>MCP-resident Typer</td><td>list, add, remove, sync, test, serve, doctor</td><td><code>mcp_package_available()</code></td></tr><tr><td>MCPServerCLI host</td><td>serve, list-tools, list-resources, config-generate, tasks, auth</td><td>Direct via <code>praisonai-mcp <cmd></code></td></tr><tr><td>Code-resident light</td><td><code>serve mcp</code></td><td>Always available with code + agents</td></tr><tr><td>Wrapper-resident</td><td>dashboard, schedule, langfuse</td><td><code>wrapper_package_available()</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Standalone <code>praisonai-code</code> hides <code>mcp</code> from <code>--help</code> when <code>praisonai-mcp</code> is not installed. Host commands like <code>praisonai-mcp serve</code> skip the <code>mcp</code> prefix; <code>praisonai mcp serve</code> goes through the Typer group.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 8 — Python API</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>import praisonai_mcp
|
||||
from praisonai_mcp.mcp_server import MCPServer
|
||||
from praisonai_mcp.mcp_server.registry import register_tool
|
||||
from praisonai_mcp.mcp_server.adapters import register_all
|
||||
|
||||
@register_tool("custom.greet")
|
||||
def greet(name: str) -> str:
|
||||
"""Greet a person by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
register_all()
|
||||
server = MCPServer(name="praisonai")
|
||||
server.run(transport="stdio")</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Recipe host
|
||||
from praisonai_mcp.mcp_server import RecipeMCPAdapter
|
||||
|
||||
adapter = RecipeMCPAdapter("support-reply")
|
||||
adapter.load()
|
||||
adapter.to_mcp_server().run(transport="stdio")
|
||||
|
||||
# Shim path still works
|
||||
from praisonai.mcp_server import MCPServer</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 9 — Standalone (no wrapper)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install praisonaiagents "praisonai-mcp[all]"
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
praisonai-mcp serve --transport stdio
|
||||
praisonai-mcp list-tools
|
||||
praisonai-mcp doctor
|
||||
praisonai-mcp --help</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Heavy MCP hosting needs only <code>praisonaiagents</code> + <code>praisonai-mcp</code>. Full capability registry (workflows, deploy, extended tools) activates when the wrapper is co-installed via <code>_wrapper_bridge</code>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Bridges (no PyPI cycles)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Bridge</th><th>Direction</th><th>Use</th></tr></thead><tbody><tr><td><code>_mcp_bridge</code></td><td>code → mcp</td><td>Route <code>praisonai mcp</code> into Typer group</td></tr><tr><td><code>_code_bridge</code></td><td>mcp → code</td><td>Config loader, paths, output controller</td></tr><tr><td><code>_wrapper_bridge</code></td><td>mcp → wrapper</td><td>Capabilities, recipe, deploy adapters</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Backward-compat shims</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Old path</th><th>Still works via</th></tr></thead><tbody><tr><td><code>praisonai.mcp_server.*</code></td><td><code>alias_package</code> → <code>praisonai_mcp.mcp_server</code></td></tr><tr><td><code>praisonai.cli.commands.mcp</code></td><td><code>sys.modules</code> shim → mcp Typer group</td></tr><tr><td><code>from praisonai.mcp_server import MCPServer</code></td><td>Lazy re-export through shim</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Seven-package publish order</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonaiagents → praisonai-code → praisonai-bot → praisonai-train → praisonai-browser → praisonai-mcp → praisonai (wrapper)</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Wrapper pins <code>praisonai-mcp>=X</code> after mcp is on PyPI. CI: <code>.github/workflows/pypi-release.yml</code>. Import gate: <code>scripts/check_c12_mcp_imports.sh</code>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Summary</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Item</th><th>Value</th></tr></thead><tbody><tr><td>Tier</td><td>2e</td></tr><tr><td>PyPI</td><td><code>praisonai-mcp</code> v0.0.2</td></tr><tr><td>Runtime deps</td><td><code>praisonaiagents</code>, <code>mcp</code>, rich, typer</td></tr><tr><td>HTTP extra</td><td><code>[server]</code> — starlette, uvicorn</td></tr><tr><td>Auth extra</td><td><code>[auth]</code> — httpx</td></tr><tr><td>Transports</td><td><code>stdio</code>, <code>http-stream</code></td></tr><tr><td>Light alternative</td><td><code>praisonai serve mcp</code> (code tier)</td></tr><tr><td>CLI entry</td><td><code>praisonai-mcp</code> or <code>praisonai mcp</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Previous: <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train</a> · Next: praisonai wrapper</p>
|
||||
<!-- /wp:paragraph -->
|
||||
@@ -0,0 +1,460 @@
|
||||
<!-- wp:paragraph -->
|
||||
<p>praisonai is Tier 3 — the integration wrapper that wires <code>praisonaiagents</code> + five Tier-2 packages (<code>praisonai-code</code>, <code>praisonai-bot</code>, <code>praisonai-train</code>, <code>praisonai-browser</code>, <code>praisonai-mcp</code>) into one install, with dashboard, framework adapters, jobs API, serve orchestration, and backward-compat shims. v4.6.150.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:merpress/mermaidjs {"align":"full"} -->
|
||||
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid alignfull"><pre class="mermaid">%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "lineColor": "#000000"}}}%%
|
||||
flowchart TB
|
||||
subgraph T1 [Tier 1 praisonaiagents]
|
||||
A[Agent tools memory MCP client]
|
||||
end
|
||||
subgraph T2 [Tier 2 packages]
|
||||
C[praisonai-code run chat code]
|
||||
B[praisonai-bot gateway channels]
|
||||
TR[praisonai-train fine-tune agents]
|
||||
BR[praisonai-browser CDP Playwright]
|
||||
MCP[praisonai-mcp server host]
|
||||
end
|
||||
subgraph T3 [Tier 3 praisonai wrapper]
|
||||
W[dashboard jobs frameworks serve shims]
|
||||
end
|
||||
A --> C
|
||||
A --> B
|
||||
A --> TR
|
||||
A --> BR
|
||||
A --> MCP
|
||||
W --> C
|
||||
W --> B
|
||||
W --> TR
|
||||
W --> BR
|
||||
W --> MCP
|
||||
W --> A
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
class A agent
|
||||
class W agent
|
||||
class C tool
|
||||
class B tool
|
||||
class TR tool
|
||||
class BR tool
|
||||
class MCP tool
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Seven-package stack</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonaiagents → praisonai-code + praisonai-bot + praisonai-train + praisonai-browser + praisonai-mcp → praisonai (wrapper)</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Each Tier-2 package is installable standalone with its own console script. The wrapper pins all of them and preserves every old import path via shims. Use the wrapper when you need several surfaces together — HTTP serve, dashboard, framework YAML, jobs/recipe — without managing tier pins yourself.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Product catalog (C0–C12 shipped)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Epic</th><th>Choose this when…</th><th>Standalone install</th></tr></thead><tbody><tr><td><code>praisonaiagents</code></td><td>Tier 1</td><td>Building or embedding agents in code — Agent, tools, memory, hooks</td><td><code>pip install praisonaiagents</code></td></tr><tr><td><code>praisonai-code</code></td><td>C0–C8</td><td>Terminal agentic CLI — run, chat, code, warm daemon</td><td><code>pip install praisonai-code</code></td></tr><tr><td><code>praisonai-bot</code></td><td>C9</td><td>Messaging bots + WebSocket gateway</td><td><code>pip install "praisonai-bot[gateway,bot]"</code></td></tr><tr><td><code>praisonai-train</code></td><td>C10</td><td>Agent training loops + GPU LLM fine-tuning</td><td><code>pip install praisonai-train</code> or <code>[llm]</code></td></tr><tr><td><code>praisonai-browser</code></td><td>C11</td><td>Chrome extension bridge, CDP, Playwright agents</td><td><code>pip install "praisonai-browser[all]"</code></td></tr><tr><td><code>praisonai-mcp</code></td><td>C12</td><td>MCP server for Cursor, Claude Desktop, Windsurf</td><td><code>pip install "praisonai-mcp[all]"</code></td></tr><tr><td><code>praisonai</code></td><td>Tier 3</td><td>Full product / umbrella — serve, dashboard, frameworks, jobs</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Three-layer products (do not conflate)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Product</th><th>Layer 1 (agents)</th><th>Layer 2 (dedicated pkg)</th><th>Layer 3 (wrapper)</th></tr></thead><tbody><tr><td>Browser</td><td><code>tools/protocols/browser.py</code></td><td><code>praisonai-browser</code></td><td>shims + optional full stack</td></tr><tr><td>MCP</td><td><code>praisonaiagents/mcp</code> client + light server</td><td><code>praisonai-mcp</code> heavy host</td><td>capability bodies via <code>_wrapper_bridge</code></td></tr><tr><td>Bots</td><td>bots/gateway protocols</td><td><code>praisonai-bot</code></td><td>shims only</td></tr><tr><td>Train</td><td>Agent + hooks in agents</td><td><code>praisonai-train</code></td><td>shims + <code>setup-conda-env</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Install matrix</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>1. Base install (full seven-package stack)</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install praisonai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
praisonai doctor
|
||||
praisonai version show</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>2. Targeted extras</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai[claw]" # dashboard + bots + gateway
|
||||
pip install "praisonai[all]" # onboarding + messaging + search stack
|
||||
pip install "praisonai[train]" # LLM fine-tuning (Unsloth stack)
|
||||
pip install "praisonai[mcp]" # full MCP server host
|
||||
pip install "praisonai[browser]" # extension bridge + CDP + Playwright
|
||||
pip install "praisonai[crewai]" # multi-framework YAML</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Min version (wrapper pin)</th><th>Console script</th></tr></thead><tbody><tr><td><code>praisonaiagents</code></td><td>≥ 1.6.153</td><td>—</td></tr><tr><td><code>praisonai-code</code></td><td>≥ 0.0.49</td><td><code>praisonai-code</code></td></tr><tr><td><code>praisonai-bot</code></td><td>≥ 0.0.34</td><td><code>praisonai-bot</code></td></tr><tr><td><code>praisonai-train</code></td><td>≥ 0.0.5</td><td><code>praisonai-train</code></td></tr><tr><td><code>praisonai-browser</code></td><td>≥ 0.0.2</td><td><code>praisonai-browser</code></td></tr><tr><td><code>praisonai-mcp</code></td><td>≥ 0.0.2</td><td><code>praisonai-mcp</code></td></tr><tr><td><code>praisonai</code></td><td>4.6.150</td><td><code>praisonai</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:separator -->
|
||||
<hr class="wp-block-separator has-alpha-channel-opacity"/>
|
||||
<!-- /wp:separator -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 1 — What wrapper adds (not in Tier-2 alone)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Feature</th><th>Command / path</th><th>Owner</th><th>Extracted?</th></tr></thead><tbody><tr><td>Unified CLI router</td><td><code>praisonai …</code></td><td>code Typer + 6 resident registries</td><td>—</td></tr><tr><td>HTTP serve orchestration</td><td><code>praisonai serve agents|recipe|a2a|…</code></td><td>wrapper <code>cli/features/serve.py</code></td><td>Deferred (C15 epic)</td></tr><tr><td>Dashboard launcher</td><td><code>praisonai dashboard</code></td><td>wrapper</td><td>Stay wrapper</td></tr><tr><td>Framework adapters</td><td><code>praisonai --framework crewai</code></td><td>wrapper <code>framework_adapters/</code></td><td>Stay wrapper (impls in external PraisonAI-Frameworks)</td></tr><tr><td>Jobs API</td><td><code>praisonai.jobs</code> · port 8005</td><td>wrapper</td><td>Deferred (with serve/recipe)</td></tr><tr><td>Recipe NL → YAML</td><td><code>praisonai recipe …</code></td><td>wrapper</td><td>Deferred</td></tr><tr><td>Scheduler CLI</td><td><code>praisonai schedule …</code></td><td>wrapper</td><td>Stay wrapper (3-layer split with bot)</td></tr><tr><td>Sandbox execution</td><td><code>praisonai sandbox …</code></td><td>wrapper <code>sandbox/</code></td><td>C13 candidate</td></tr><tr><td>Cloud deploy</td><td><code>praisonai deploy …</code></td><td>wrapper <code>deploy/</code></td><td>C13 candidate</td></tr><tr><td>Langfuse / Langflow / n8n</td><td><code>langfuse</code>, <code>flow</code>, <code>n8n</code></td><td>wrapper</td><td>Stay wrapper</td></tr><tr><td>Backward-compat shims</td><td><code>praisonai.bots</code> → <code>praisonai_bot</code></td><td>wrapper <code>cli/_shim.py</code></td><td>Permanent</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Tier-2 packages own their domains; the wrapper owns integration surfaces that span multiple tiers.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 2 — Terminal hot path (via code tier)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install praisonai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
praisonai run "Summarise the latest AI agent trends"
|
||||
praisonai chat
|
||||
praisonai code
|
||||
praisonai daemon start --background
|
||||
praisonai run "Hello" # auto-forwards to warm runtime</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Python SDK (Tier 1 — same everywhere)
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(instructions="You are a helpful AI assistant")
|
||||
agent.start("Write a movie script about a robot on Mars")</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-code-praisonai-code-terminal-cli-in-depth/">praisonai-code in-depth</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 3 — Train (via praisonai-train tier)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># LLM fine-tuning (Unsloth) — lazy ML deps
|
||||
praisonai train llm dataset.json
|
||||
praisonai train llm --model llama-3.1 dataset.json
|
||||
|
||||
# Agent training — LLM-as-judge
|
||||
praisonai train agents --input "What is Python?" --iterations 3
|
||||
|
||||
# Human feedback loop
|
||||
praisonai train agents --input "Explain quantum computing" --human
|
||||
|
||||
# Session management
|
||||
praisonai train list
|
||||
praisonai train apply train-abc123 --run "And Germany?"
|
||||
|
||||
# Standalone script (no wrapper prefix)
|
||||
praisonai-train agents --input "Hello" --iterations 2</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train in-depth</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 4 — Bots & gateway (via bot tier)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai[bot]"
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export TELEGRAM_BOT_TOKEN=...
|
||||
|
||||
praisonai onboard
|
||||
praisonai bot start --platform telegram
|
||||
praisonai gateway start --config ~/.praisonai/bot.yaml
|
||||
praisonai gateway install --config ~/.praisonai/bot.yaml
|
||||
praisonai pairing approve telegram ABC12345</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-bot-praisonai-bot-gateway-channels-in-depth/">praisonai-bot in-depth</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 5 — MCP server host (via mcp tier)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Command</th><th>Layer</th><th>Package</th></tr></thead><tbody><tr><td><code>praisonai serve mcp</code></td><td>Light server</td><td><code>praisonai-code</code> + agents</td></tr><tr><td><code>praisonai mcp serve</code></td><td>Heavy host</td><td><code>praisonai-mcp</code></td></tr><tr><td><code>praisonai-mcp serve</code></td><td>Heavy host (direct)</td><td><code>praisonai-mcp</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Heavy MCP host — full tool/resource/prompt registry
|
||||
praisonai mcp serve --transport stdio
|
||||
praisonai mcp serve --transport http-stream --port 8080 --api-key secret
|
||||
|
||||
# Recipe → scoped MCP server
|
||||
praisonai mcp serve-recipe support-reply --transport stdio
|
||||
|
||||
# Client config management (needs praisonai-code co-installed)
|
||||
praisonai mcp list
|
||||
praisonai mcp add filesystem --command npx --args "-y @modelcontextprotocol/server-filesystem ."
|
||||
|
||||
# Generate Cursor / Claude Desktop config
|
||||
praisonai mcp config-generate --client cursor --transport stdio</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-mcp-praisonai-mcp-server-host-client-config-in-depth/">praisonai-mcp in-depth</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 6 — Browser automation (via browser tier)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai[browser]"
|
||||
|
||||
# Extension bridge server + LLM browser agent
|
||||
praisonai browser start
|
||||
praisonai browser run "Find the latest Python release notes"
|
||||
|
||||
# Diagnostics
|
||||
praisonai browser doctor
|
||||
|
||||
# Lightweight tool shim (code tier — no praisonai-browser required)
|
||||
praisonai browser-tool snapshot https://example.com
|
||||
|
||||
# Standalone script
|
||||
praisonai-browser start
|
||||
praisonai-browser run "Click the login button"</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Three layers: agents protocols → <code>praisonai browser-tool</code> (praisonai-tools) → <code>praisonai browser</code> (extension bridge, CDP, Playwright).</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-browser-praisonai-browser-browser-automation-in-depth/">praisonai-browser in-depth</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 7 — Dashboard stack</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai[claw,flow,ui]"
|
||||
|
||||
# Individual services
|
||||
praisonai claw # :8082 — ops dashboard
|
||||
praisonai flow # :7861 — Langflow
|
||||
praisonai ui # :8081 — clean chat
|
||||
|
||||
# Unified launcher (wrapper-only)
|
||||
praisonai dashboard # :3000
|
||||
praisonai dashboard --port 9000 --no-auto-start</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Port</th><th>Service</th></tr></thead><tbody><tr><td>3000</td><td>Dashboard hub</td></tr><tr><td>7860/7861</td><td>Langflow</td></tr><tr><td>8081</td><td>UI chat</td></tr><tr><td>8082</td><td>Claw</td></tr><tr><td>8765</td><td>Gateway WebSocket</td></tr><tr><td>8005</td><td>Jobs API</td></tr><tr><td>8080</td><td>MCP HTTP stream (optional)</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 8 — Multi-framework YAML</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>pip install "praisonai[crewai]"
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
praisonai --framework crewai --auto "Create a movie script"
|
||||
praisonai run --file agents.yaml --framework crewai
|
||||
|
||||
pip install "praisonai[autogen]"
|
||||
praisonai --framework autogen agents.yaml</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Protocols live in <code>praisonaiagents/frameworks/</code>; adapter implementations in wrapper <code>framework_adapters/</code>. Entry-point group: <code>praisonai.framework_adapters</code>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 9 — Scheduler (24/7 agents)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonai schedule add --agent my_agent.yaml --expr "hourly"
|
||||
praisonai schedule list
|
||||
praisonai schedule start</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Python
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="NewsChecker", instructions="Check latest AI news")
|
||||
scheduler = AgentScheduler(agent=agent, task="Summarise top 3 AI stories")
|
||||
scheduler.start("hourly", run_immediately=True)</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Three scheduler layers: job store (<code>praisonaiagents.scheduler</code>) → CLI (<code>praisonai schedule</code>, wrapper) → gateway tick delivery (<code>praisonai_bot.scheduler.executor</code>).</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 10 — Jobs API & wrapper-only surfaces</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Jobs, recipe, serve, sandbox, and deploy remain wrapper-owned today (deferred or C13 candidates).</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># Jobs API (wrapper — port 8005)
|
||||
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
|
||||
praisonai run submit "Analyse AI trends"
|
||||
praisonai run submit "Analyse news" --recipe news-analyzer --wait
|
||||
export PRAISONAI_JOBS_API_KEY=your-secret
|
||||
|
||||
# HTTP serve (wrapper orchestration — Typer shell in code tier)
|
||||
praisonai serve agents --port 8080
|
||||
praisonai serve recipe support-reply
|
||||
|
||||
# Sandbox / deploy (wrapper today — C13 extraction candidates)
|
||||
praisonai sandbox run "print hello" --backend docker
|
||||
praisonai deploy agents.yaml --provider aws</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 11 — Lazy CLI routing (six resident registries)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Registry</th><th>Count</th><th>Examples</th><th>Loaded from</th><th>Gate</th></tr></thead><tbody><tr><td>Code-resident</td><td>~40</td><td>run, chat, code, daemon, serve, config</td><td><code>praisonai_code.cli.commands.*</code></td><td>always</td></tr><tr><td>Bot-resident</td><td>8</td><td>bot, gateway, onboard, kanban, claw</td><td><code>praisonai_bot.cli.commands.*</code></td><td><code>bot_package_available()</code></td></tr><tr><td>Train-resident</td><td>1</td><td>train</td><td><code>praisonai_train.cli.commands.*</code></td><td><code>train_package_available()</code></td></tr><tr><td>Browser-resident</td><td>1</td><td>browser</td><td><code>praisonai_browser.cli.commands.*</code></td><td><code>browser_package_available()</code></td></tr><tr><td>MCP-resident</td><td>1</td><td>mcp</td><td><code>praisonai_mcp.cli.commands.*</code></td><td><code>mcp_package_available()</code></td></tr><tr><td>Wrapper-resident</td><td>22</td><td>dashboard, schedule, flow, langfuse, recipe</td><td><code>praisonai.cli.commands.*</code></td><td><code>wrapper_available()</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonai --help # 70+ commands, lazy-loaded
|
||||
praisonai doctor
|
||||
praisonai train --help
|
||||
praisonai mcp --help
|
||||
praisonai browser --help
|
||||
praisonai dashboard --help</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Standalone <code>praisonai-code</code> hides bot/train/browser/mcp/wrapper commands from <code>--help</code> when those packages are absent.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 12 — Shim map (backward compat)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Old import / path</th><th>Canonical target</th></tr></thead><tbody><tr><td><code>praisonai.cli.main</code></td><td><code>praisonai_code.cli.main</code></td></tr><tr><td><code>praisonai.runtime.*</code></td><td><code>praisonai_code.runtime.*</code></td></tr><tr><td><code>praisonai.bots.*</code></td><td><code>praisonai_bot.bots.*</code></td></tr><tr><td><code>praisonai.gateway.*</code></td><td><code>praisonai_bot.gateway.*</code></td></tr><tr><td><code>praisonai.train.*</code></td><td><code>praisonai_train.train.*</code></td></tr><tr><td><code>praisonai.browser.*</code></td><td><code>praisonai_browser.*</code></td></tr><tr><td><code>praisonai.mcp_server.*</code></td><td><code>praisonai_mcp.mcp_server.*</code></td></tr><tr><td><code>praisonai.scheduler.executor</code></td><td><code>praisonai_bot.scheduler.executor</code></td></tr><tr><td><code>setup-conda-env</code></td><td><code>praisonai_train.setup.setup_conda_env</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code># These must keep working
|
||||
from praisonai.cli.main import PraisonAI
|
||||
from praisonai.bots import Bot
|
||||
from praisonai.gateway import WebSocketGateway
|
||||
from praisonai.train.agents import AgentTrainer
|
||||
from praisonai.mcp_server import MCPServer
|
||||
python -m praisonai run "hello"
|
||||
python -m praisonai.runtime</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Section 13 — Bridges (no PyPI cycles)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Bridge</th><th>Direction</th><th>Use</th></tr></thead><tbody><tr><td><code>_wrapper_bridge</code></td><td>code → wrapper</td><td>serve handlers, frameworks, jobs</td></tr><tr><td><code>_bot_bridge</code></td><td>code → bot</td><td>bot/gateway CLI routing</td></tr><tr><td><code>_train_bridge</code></td><td>code → train</td><td>train CLI routing</td></tr><tr><td><code>_browser_bridge</code></td><td>code → browser</td><td>browser CLI routing</td></tr><tr><td><code>_mcp_bridge</code></td><td>code → mcp</td><td>mcp CLI routing</td></tr><tr><td><code>_code_bridge</code></td><td>tier-2 → code</td><td>config kernel, legacy dispatch</td></tr><tr><td><code>_wrapper_bridge</code></td><td>tier-2 → wrapper</td><td>capabilities, recipe, jobs/UI</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">When to use wrapper vs a Tier-2 package alone</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Your goal</th><th>Install</th></tr></thead><tbody><tr><td>Only terminal agents</td><td><code>praisonai-code</code> (+ optional <code>praisonaiagents</code>)</td></tr><tr><td>Only bots on Telegram/Slack</td><td><code>praisonai-bot</code></td></tr><tr><td>Only train / fine-tune</td><td><code>praisonai-train</code></td></tr><tr><td>Only automate browser</td><td><code>praisonai-browser</code></td></tr><tr><td>Only MCP server for IDE</td><td><code>praisonai-mcp</code></td></tr><tr><td>Several of the above + serve / dashboard / framework YAML</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">What stays in wrapper (not extracted)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Surface</th><th>Why it stays</th></tr></thead><tbody><tr><td><code>dashboard</code> / <code>flow</code> / <code>ui</code></td><td>Langflow extra + launcher glue — not a standalone PyPI product</td></tr><tr><td><code>claw</code></td><td>Default app lives in <code>praisonai-bot</code>; wrapper is a thin shim</td></tr><tr><td><code>framework_adapters/</code></td><td>Thin registry; heavy impls in external PraisonAI-Frameworks</td></tr><tr><td><code>schedule</code> CLI</td><td>Three-layer split: agents store + wrapper CLI + bot executor</td></tr><tr><td><code>serve</code> / <code>recipe</code> / <code>jobs</code></td><td>~14k LOC deferred epic — high coupling (C15)</td></tr><tr><td><code>sandbox</code> / <code>deploy</code></td><td>Still in wrapper today; C13 extraction candidates</td></tr><tr><td><code>standardise</code> / suite runner</td><td>Maintainer/docs tooling — internal only</td></tr><tr><td><code>persist</code> / enterprise adapters</td><td><strong>Rejected</strong> — agents already ship File/SQLite defaults; no crisp standalone story</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Roadmap — C13+ candidates</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Status</th><th>Choose when…</th><th>Standalone install (planned)</th></tr></thead><tbody><tr><td><code>praisonai-sandbox</code></td><td><strong>C13 preferred</strong> (execution goal)</td><td>Isolated agent code execution — Docker/E2B/Modal/Daytona/Sandlock</td><td><code>pip install praisonai-sandbox[docker]</code></td></tr><tr><td><code>praisonai-deploy</code></td><td><strong>C13 alt</strong> (deploy goal)</td><td>Ship <code>agents.yaml</code> to Docker/AWS/GCP/Azure</td><td><code>pip install praisonai-deploy</code></td></tr><tr><td><code>praisonai-replay</code></td><td>Conditional C14+</td><td>Deterministic replay from run ledger / traces</td><td>After product sign-off</td></tr><tr><td><code>praisonai-serve</code></td><td>Deferred C15</td><td>HTTP API hosting without full umbrella (~14k LOC)</td><td>With recipe/jobs decision</td></tr><tr><td><code>praisonai-recipe</code></td><td>Deferred</td><td>NL → agent YAML without jobs UI</td><td>Tied to jobs + serve</td></tr><tr><td><code>praisonai-jobs</code></td><td>Deferred</td><td>Async long-running agent jobs API</td><td>Extract with serve or stay wrapper</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>C13 decision rule: <strong>sandbox</strong> = where code runs; <strong>deploy</strong> = where the agent service lives. Ship one first after product sign-off.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Standalone extraction litmus test</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Criterion</th><th>Pass (browser/MCP)</th><th>Fail (example)</th></tr></thead><tbody><tr><td>Named user goal without full <code>praisonai</code></td><td><code>pip install praisonai-browser</code> → automate Chrome</td><td><code>pip install praisonai-persist</code> → ???</td></tr><tr><td>Coherent product boundary</td><td>One Typer group / one console script</td><td>Whole <code>cli/features/</code> orchestration layer</td></tr><tr><td>PyPI deps: <code>praisonaiagents</code> only</td><td>Tier-2 + lazy <code>_wrapper_bridge</code></td><td>Hard <code>import praisonai</code> at module level</td></tr><tr><td>Worth a separate install</td><td>User who never wants dashboard/serve/bots</td><td>Internal docs tooling</td></tr><tr><td>Extraction shape</td><td>C10 single PR (~3–11k LOC)</td><td>Mega-epic without product sign-off</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">When to use which tier</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Scenario</th><th>Install</th></tr></thead><tbody><tr><td>Pure Python agents, minimal deps</td><td><code>pip install praisonaiagents</code></td></tr><tr><td>Terminal coding agent, CI, headless</td><td><code>pip install praisonai-code</code></td></tr><tr><td>Telegram/Discord gateway only</td><td><code>pip install "praisonai-bot[gateway,bot]"</code></td></tr><tr><td>Agent training (no GPU)</td><td><code>pip install praisonai-train</code></td></tr><tr><td>LLM fine-tuning (GPU)</td><td><code>pip install "praisonai-train[llm]"</code></td></tr><tr><td>MCP server for Cursor/Claude</td><td><code>pip install "praisonai-mcp[all]"</code></td></tr><tr><td>Browser automation (extension/CDP)</td><td><code>pip install "praisonai-browser[all]"</code></td></tr><tr><td>CrewAI/AutoGen YAML</td><td><code>pip install "praisonai[crewai]"</code></td></tr><tr><td>Full ops + channels + dashboard</td><td><code>pip install "praisonai[claw]"</code></td></tr><tr><td>Everything in one install</td><td><code>pip install praisonai</code></td></tr><tr><td>Library author / embedding</td><td><code>praisonaiagents</code> only</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Seven-package publish order</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>praisonaiagents
|
||||
→ praisonai-code + praisonai-bot + praisonai-train + praisonai-browser + praisonai-mcp
|
||||
→ praisonai (wrapper pins all tier-2 deps last)</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>CI: <code>.github/workflows/pypi-release.yml</code> · selective publish: <code>publish_all.py --changed-only</code> (default). Playbook per package: manifest → TDD compat tests → git mv → shims → <code>_X_RESIDENT_COMMANDS</code> → CI shard → PyPI.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">Summary</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Item</th><th>Value</th></tr></thead><tbody><tr><td>Tier</td><td>3 (integration wrapper)</td></tr><tr><td>PyPI</td><td><code>praisonai</code> v4.6.150</td></tr><tr><td>Shipped tier-2 deps</td><td>code, bot, train, browser, mcp (C0–C12)</td></tr><tr><td>Resident registries</td><td>6 (code + bot + train + browser + mcp + wrapper)</td></tr><tr><td>Wrapper-only (~140k LOC)</td><td>serve, recipe, jobs, dashboard, sandbox, deploy, frameworks</td></tr><tr><td>Next extraction</td><td>C13: sandbox or deploy (product sign-off)</td></tr><tr><td>One-liner still works</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Architecture overview: Seven-package C7–C12 extraction · Post-C12 roadmap (C13+)</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Series: <a href="https://mer.vin/2026/07/praisonai-code-praisonai-code-terminal-cli-in-depth/">praisonai-code</a> · <a href="https://mer.vin/2026/07/praisonai-bot-praisonai-bot-gateway-channels-in-depth/">praisonai-bot</a> · <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train</a> · <a href="https://mer.vin/2026/07/praisonai-browser-praisonai-browser-browser-automation-in-depth/">praisonai-browser</a> · <a href="https://mer.vin/2026/07/praisonai-mcp-praisonai-mcp-server-host-client-config-in-depth/">praisonai-mcp</a> · praisonai wrapper</p>
|
||||
<!-- /wp:paragraph -->
|
||||
@@ -17,7 +17,6 @@ GROQ_API_KEY=gsk_... # Groq (fast inference)
|
||||
COHERE_API_KEY=... # Cohere models
|
||||
AZURE_OPENAI_API_KEY=... # Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
|
||||
EVOLINK_API_KEY=... # EvoLink.AI OpenAI-compatible models. Get yours at https://evolink.ai/dashboard/keys
|
||||
|
||||
# === Optional: Memory backends ===
|
||||
MEM0_API_KEY=... # Mem0 memory service
|
||||
|
||||
@@ -6,11 +6,6 @@ labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## SDK / package
|
||||
- [ ] Python (`pip install praisonai` / `praisonaiagents`)
|
||||
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes in `src/praisonai-ts/`
|
||||
- [ ] Cross-language / parity (file here on PraisonAI)
|
||||
|
||||
## Environment
|
||||
- Provider (select one):
|
||||
- [ ] Anthropic
|
||||
@@ -19,7 +14,6 @@ assignees: ''
|
||||
- [ ] AWS Bedrock
|
||||
- [ ] Other: <!-- specify -->
|
||||
- PraisonAI version: <!-- if known -->
|
||||
- Node.js version (TS/JS only): <!-- e.g. 20.x -->
|
||||
- Operating System: <!-- e.g. macOS 14.3, Windows 11, Ubuntu 22.04 -->
|
||||
|
||||
## Full Code
|
||||
@@ -37,4 +31,4 @@ assignees: ''
|
||||
<!-- What actually happened -->
|
||||
|
||||
## Additional Context
|
||||
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
|
||||
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
|
||||
@@ -228,7 +228,6 @@ runs:
|
||||
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
|
||||
4. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
|
||||
5. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
|
||||
6. TypeScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md for TS rules).
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
|
||||
@@ -33,20 +33,18 @@ steps:
|
||||
Second, read the issue: execute_command 'cat .github/triage-context.md'. Do NOT pass cwd.
|
||||
|
||||
Third, read architecture guidelines: execute_command 'cat src/praisonai-agents/AGENTS.md'.
|
||||
If the issue is TypeScript/JavaScript SDK, also execute_command 'cat src/praisonai-ts/AGENTS.md'.
|
||||
|
||||
Fourth, decide routing before planning:
|
||||
- TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md).
|
||||
- Tools → PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs).
|
||||
- Plugins → PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails).
|
||||
- Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point.
|
||||
Core SDK/wrapper for Python protocols and CLI; external repos for Tools/Plugins/Docs only.
|
||||
Core SDK/wrapper only for protocols and CLI — external repos for Tools/Plugins.
|
||||
|
||||
Fifth, decide if this issue requires code changes:
|
||||
- If YES: identify the exact files and lines that must change. Output a concrete file-level plan.
|
||||
- If NO (question, discussion, duplicate, invalid): say so clearly. No code changes needed.
|
||||
|
||||
Output: routing decision (Core / Wrapper / TS-monorepo / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
|
||||
Output: routing decision (Core / Wrapper / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
|
||||
expected_output: A decision on whether code changes are needed, and if so a concrete file-level plan.
|
||||
|
||||
- name: Implement Fix
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
|
||||
*/
|
||||
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]', 'praisonai-triage-agent[bot]']);
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
|
||||
@@ -272,16 +272,6 @@ assert('sdk with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
|
||||
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
|
||||
]) === null);
|
||||
assert('ts sdk without tests', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
|
||||
]) !== null);
|
||||
assert('ts sdk with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
|
||||
{ filename: 'src/praisonai-ts/tests/unit/agent/history-restore.test.ts', additions: 10 },
|
||||
]) === null);
|
||||
assert('touches ts sdk', mg.touchesSdk([{ filename: 'src/praisonai-ts/src/agent/simple.ts' }]));
|
||||
assert('final body requires sdk value table', mg.FINAL_CLAUDE_REVIEW_BODY.includes('| **SDK value** |'));
|
||||
assert('final body requires ts agents md', mg.FINAL_CLAUDE_REVIEW_BODY.includes('src/praisonai-ts/AGENTS.md'));
|
||||
|
||||
// PR size
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
|
||||
@@ -297,33 +287,4 @@ assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
|
||||
'PraisonAI'
|
||||
));
|
||||
|
||||
assert('dispatch blocked when merge gate active', mg.mergeGateDispatchBlockedReason({
|
||||
labels: ['claude-merge-gate-active'],
|
||||
pendingRuns: 0,
|
||||
}) === 'merge gate already active on PR');
|
||||
assert('dispatch blocked when queue saturated', mg.mergeGateDispatchBlockedReason({
|
||||
labels: [],
|
||||
pendingRuns: 3,
|
||||
})?.includes('already queued or in progress'));
|
||||
assert('dispatch allowed when queue below cap', mg.mergeGateDispatchBlockedReason({
|
||||
labels: [],
|
||||
pendingRuns: 2,
|
||||
}) == null);
|
||||
|
||||
// Pending count excludes workflow_run auto-dispatch probes (PR #4534)
|
||||
assert('workflow_run probes excluded from pending count', mg.countSubstantiveMergeGateRuns([
|
||||
{ event: 'workflow_dispatch' },
|
||||
{ event: 'workflow_run' },
|
||||
{ event: 'workflow_run' },
|
||||
]) === 1);
|
||||
assert('all-probe runs count as zero pending', mg.countSubstantiveMergeGateRuns([
|
||||
{ event: 'workflow_run' },
|
||||
{ event: 'workflow_run' },
|
||||
]) === 0);
|
||||
assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns([
|
||||
{ event: 'workflow_dispatch' },
|
||||
{ event: 'schedule' },
|
||||
]) === 2);
|
||||
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -17,8 +17,7 @@ const PR_MAX_AUTO_FILES = 30;
|
||||
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
|
||||
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
|
||||
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
|
||||
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/', 'src/praisonai-ts/'];
|
||||
const TS_SDK_SOURCE_PREFIX = 'src/praisonai-ts/src/';
|
||||
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
|
||||
const SENSITIVE_PATH_PATTERNS = [
|
||||
/^\.github\/workflows\//,
|
||||
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
|
||||
@@ -27,17 +26,7 @@ const SENSITIVE_PATH_PATTERNS = [
|
||||
/\.env(\.|$)/,
|
||||
/credentials\.json$/i,
|
||||
];
|
||||
const REQUIRED_SDK_CHECK_PATTERNS = [
|
||||
/test/i,
|
||||
/smoke/i,
|
||||
/core/i,
|
||||
/python package/i,
|
||||
/comprehensive/i,
|
||||
/optimized/i,
|
||||
/praisonai-ts/i,
|
||||
/npm/i,
|
||||
/webview/i,
|
||||
];
|
||||
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
|
||||
const SECRET_PATTERNS = [
|
||||
/sk-[a-zA-Z0-9]{20,}/,
|
||||
/AKIA[0-9A-Z]{16}/,
|
||||
@@ -319,7 +308,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Review changes in this PR. Python SDK: praisonaiagents, praisonai. TypeScript SDK: src/praisonai-ts/. Do NOT modify src/praisonai-rust. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**MANDATORY READ (before reviewing):**\n- Always read src/praisonai-agents/AGENTS.md\n- If this PR touches src/praisonai-ts/, also read src/praisonai-ts/AGENTS.md §2.1.2 (TS triage + PR review checklist)\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment:**\n#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|\n| Protocol-driven / no heavy impl in core | ✅ or ❌ + one-line rationale |\n| Backward compatible | ✅ or ❌ + one-line rationale |\n| Performance (hot path) | ✅ or ❌ + one-line rationale |\n| **SDK value** | ✅ or ❌ + one-line rationale (explicitly judge whether the change strengthens the SDK) |\n| No Agent param bloat | ✅ or ❌ + one-line rationale |\n| Repo routing | ✅ or ❌ + one-line rationale |\n\nFor TypeScript PRs (src/praisonai-ts/), also add:\n| TS types / parity / tests | ✅ or ❌ + one-line rationale (npm run build && npm test) |\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
@@ -475,61 +464,6 @@ function hasBlockingClaudeRunForPr(runs, headRef) {
|
||||
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
|
||||
}
|
||||
|
||||
const MERGE_GATE_WORKFLOW_FILE = 'claude-merge-gate.yml';
|
||||
const MERGE_GATE_ACTIVE_LABEL = 'claude-merge-gate-active';
|
||||
const MAX_PENDING_MERGE_GATE_RUNS = 3;
|
||||
|
||||
function mergeGateDispatchBlockedReason({ labels, pendingRuns, maxPending = MAX_PENDING_MERGE_GATE_RUNS }) {
|
||||
if ((labels || []).includes(MERGE_GATE_ACTIVE_LABEL)) {
|
||||
return 'merge gate already active on PR';
|
||||
}
|
||||
if (pendingRuns >= maxPending) {
|
||||
return `${pendingRuns} merge gate run(s) already queued or in progress (max ${maxPending})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// workflow_run probes (auto-dispatch) share concurrency and must not count
|
||||
// toward backpressure — they blocked dispatches for merge-ready PRs.
|
||||
function countSubstantiveMergeGateRuns(runs) {
|
||||
return (runs || []).filter((r) => r && r.event !== 'workflow_run').length;
|
||||
}
|
||||
|
||||
async function countPendingMergeGateRuns(github, owner, repo) {
|
||||
let pending = 0;
|
||||
for (const status of ['queued', 'in_progress']) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: MERGE_GATE_WORKFLOW_FILE,
|
||||
status,
|
||||
per_page: 30,
|
||||
});
|
||||
pending += countSubstantiveMergeGateRuns(data.workflow_runs);
|
||||
} catch {
|
||||
// Best-effort backpressure only.
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
|
||||
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
|
||||
const reason = mergeGateDispatchBlockedReason({
|
||||
labels: ctx.labels,
|
||||
pendingRuns,
|
||||
maxPending,
|
||||
});
|
||||
if (reason) {
|
||||
core?.info?.(`Skip merge gate dispatch for PR #${prNumber}: ${reason}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
@@ -693,35 +627,18 @@ function prSizeReasons(files) {
|
||||
}
|
||||
|
||||
function missingTestsReason(files) {
|
||||
const pySdkAdds = files.filter(
|
||||
const sdkAdds = files.filter(
|
||||
(f) =>
|
||||
f.filename.startsWith('src/praisonai-agents/') &&
|
||||
f.filename.endsWith('.py') &&
|
||||
!f.filename.endsWith('__init__.py') &&
|
||||
(f.additions || 0) > 0
|
||||
);
|
||||
const tsSdkAdds = files.filter(
|
||||
(f) =>
|
||||
f.filename.startsWith(TS_SDK_SOURCE_PREFIX) &&
|
||||
f.filename.endsWith('.ts') &&
|
||||
!f.filename.endsWith('.d.ts') &&
|
||||
(f.additions || 0) > 0
|
||||
);
|
||||
if (pySdkAdds.length === 0 && tsSdkAdds.length === 0) return null;
|
||||
const hasPyTestChange = files.some(
|
||||
if (sdkAdds.length === 0) return null;
|
||||
const hasTestChange = files.some(
|
||||
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
|
||||
);
|
||||
const hasTsTestChange = files.some(
|
||||
(f) =>
|
||||
f.filename.startsWith('src/praisonai-ts/tests/') ||
|
||||
/\.(test|spec)\.ts$/.test(f.filename)
|
||||
);
|
||||
if (pySdkAdds.length > 0 && !hasPyTestChange) {
|
||||
return 'Python SDK code added without test file changes — requires manual review';
|
||||
}
|
||||
if (tsSdkAdds.length > 0 && !hasTsTestChange) {
|
||||
return 'TS SDK code added without test file changes — requires manual review';
|
||||
}
|
||||
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1096,11 +1013,4 @@ module.exports = {
|
||||
isAutomatedFallbackVerdict,
|
||||
AUTOMATED_FALLBACK_MARKER,
|
||||
findMergeGateVerdict,
|
||||
MERGE_GATE_WORKFLOW_FILE,
|
||||
MERGE_GATE_ACTIVE_LABEL,
|
||||
MAX_PENDING_MERGE_GATE_RUNS,
|
||||
mergeGateDispatchBlockedReason,
|
||||
countSubstantiveMergeGateRuns,
|
||||
countPendingMergeGateRuns,
|
||||
shouldSkipMergeGateDispatch,
|
||||
};
|
||||
@@ -181,10 +181,10 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
|
||||
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
|
||||
if (!readyCandidates.length) return 0;
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const cand of readyCandidates) {
|
||||
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, cand.prNumber, core)) {
|
||||
if ((cand.labels || []).includes('claude-merge-gate-active')) {
|
||||
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
|
||||
@@ -42,5 +42,5 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript → src/praisonai-ts/; agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
|
||||
});
|
||||
@@ -33,8 +33,7 @@ on:
|
||||
- claude-merge-gate
|
||||
|
||||
concurrency:
|
||||
# Serialize workflow_run auto-dispatches (Core Tests / Claude Assistant fan-out).
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || (github.event_name == 'workflow_run' && 'auto-dispatch') || 'scan' }}
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -108,10 +107,6 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
@@ -293,7 +288,7 @@ jobs:
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens the relevant SDK (Python: praisonaiagents/praisonai; TypeScript: src/praisonai-ts/) — no scope creep. The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it. FINAL architecture review must include the Phase 1 table with an explicit **SDK value** row.
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
|
||||
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
|
||||
5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
|
||||
@@ -100,19 +100,18 @@ jobs:
|
||||
'',
|
||||
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
|
||||
'',
|
||||
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins). TypeScript/JavaScript SDK fixes land in `src/praisonai-ts/` in this repo.'
|
||||
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
// --- Auto-trigger Claude for bug/enhancement reports ---
|
||||
// --- Auto-trigger Claude for owner issues and actionable external bug/enhancement reports ---
|
||||
const titleLower = (issue.title || '').toLowerCase();
|
||||
const bodyRaw = issue.body || '';
|
||||
const hasBugMarker = /#?\s*\[bug\]/i.test(issue.title || '') || /#?\s*\[bug\]/i.test(bodyRaw);
|
||||
const hasActionableTitle = /^(\[[^\]]+\]|bug:|fix[:(]|feat:|ux:|enhancement:)/i.test(titleLower);
|
||||
const isBugReport = labels.includes('bug') || hasBugMarker;
|
||||
const isEnhancement = labels.includes('enhancement') && !labels.includes('question');
|
||||
const autoClaude = isOwner || isBugReport || (hasActionableTitle && isEnhancement);
|
||||
const autoClaude = isOwner || (hasActionableTitle && (
|
||||
labels.includes('bug') ||
|
||||
(labels.includes('enhancement') && !labels.includes('question'))
|
||||
));
|
||||
if (autoClaude) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
@@ -294,7 +293,7 @@ jobs:
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
SCOPE: Python SDK (praisonaiagents, praisonai) and TypeScript SDK (src/praisonai-ts/) live in this monorepo. Do NOT modify src/praisonai-rust. For TS issues read src/praisonai-ts/AGENTS.md.
|
||||
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
|
||||
AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage (issue triage, fix planning, PR review/implementation) and reject scope creep for the sake of adding features. Prefer existing Agent capabilities (instructions/backstory/tools/hooks/memory) over new params/modules/exports. Even when fixing a raised issue, keep the fix minimal and lightweight — do NOT expand the surface area or add knobs with no live consumer.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
@@ -306,7 +305,6 @@ jobs:
|
||||
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
|
||||
5. Plugins → MervinPraison/PraisonAI-Plugins: framework lifecycle extensions (tracing, logging, metrics, hooks, guardrails). Not agent tools — use STEP 3-ALT.
|
||||
6. Sandbox backends (optional/niche, e.g. Capsule) → PraisonAI-Plugins via `praisonai.sandbox` entry point; core keeps protocol/config/manager only.
|
||||
7. TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (not praisonaiagents/).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory). If it does, reject the change as scope creep and prefer the existing capability — the aim is lightweight and powerful, not more knobs.
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
@@ -338,11 +336,9 @@ jobs:
|
||||
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
|
||||
|
||||
<body>"
|
||||
h) Comment on THIS PraisonAI issue with the external PR URL.
|
||||
|
||||
STEP 4 — TEST:
|
||||
- For Python SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For TypeScript SDK changes: cd src/praisonai-ts && npm install && npm run build && npm test
|
||||
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
@@ -355,8 +351,6 @@ jobs:
|
||||
Bash(conda:*)
|
||||
Bash(pytest:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(node:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(poetry:*)
|
||||
@@ -376,10 +370,9 @@ jobs:
|
||||
steps.check_fork.outputs.is_pr != 'true' &&
|
||||
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
|
||||
github.event.issue != null
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
# Build the desktop app for macOS, Windows and Linux, and attach it to the
|
||||
# release.
|
||||
#
|
||||
# Releases carried no assets at all -- v4.7.1 shipped zero -- so there was
|
||||
# nothing for anyone to download. The macOS half of this shipped first; this
|
||||
# adds the other two, which were not merely missing from CI but impossible:
|
||||
# the bundle targets were `["app", "dmg"]`, both macOS-only, so a Windows or
|
||||
# Linux build produced zero artifacts and still exited zero.
|
||||
#
|
||||
# Unsigned, deliberately, on every platform. macOS signing needs a Developer ID
|
||||
# and Windows signing now needs a hardware-backed certificate or a hosted
|
||||
# signing service -- neither is in secrets. Shipping builds people can open
|
||||
# past the warning is better than shipping none, and the release notes say so
|
||||
# rather than letting Gatekeeper and SmartScreen say it for us.
|
||||
name: Desktop release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing release tag to attach the build to'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
# Queue rather than cancel: a cancelled release build looks identical to a
|
||||
# release that simply never produced assets, which is the bug being fixed.
|
||||
group: desktop-release-${{ github.event.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# macos-15, not macos-14: that image entered deprecation on
|
||||
# 2026-07-06 and is fully unsupported from 2026-11-02, with brownout
|
||||
# periods failing jobs before then. This is the only Apple-silicon
|
||||
# leg, so losing it means no arm64 DMG at all.
|
||||
- runner: macos-15 # Apple silicon
|
||||
target: aarch64-apple-darwin
|
||||
bundles: dmg
|
||||
ext: dmg
|
||||
asset: macos-apple-silicon
|
||||
# macos-15-intel, not macos-13: that image was retired on
|
||||
# 2025-12-04, so the leg would never schedule and the verify job
|
||||
# below would correctly report a missing asset on every release,
|
||||
# forever. macos-15-intel is GitHub's named x86_64 replacement.
|
||||
- runner: macos-15-intel # Intel
|
||||
target: x86_64-apple-darwin
|
||||
bundles: dmg
|
||||
ext: dmg
|
||||
asset: macos-intel
|
||||
- runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundles: nsis
|
||||
ext: exe
|
||||
asset: windows-x64-setup
|
||||
# ubuntu-22.04 rather than -latest on purpose: glibc is forward
|
||||
# compatible only, so the build host sets the *oldest* distribution
|
||||
# the .deb can run on. Building on 24.04 would produce a package that
|
||||
# refuses to start on 22.04 and Debian 12 with a "GLIBC_2.38 not
|
||||
# found" that no runtime flag can fix.
|
||||
#
|
||||
# This image begins deprecation on 2026-09-17 and is retired on
|
||||
# 2027-04-17. When it goes, move to `ubuntu-latest` with
|
||||
# `container: debian:12` rather than bumping the runner -- that keeps
|
||||
# the glibc floor decided by us instead of by GitHub's image
|
||||
# lifecycle.
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
bundles: deb
|
||||
ext: deb
|
||||
asset: linux-x64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Retried: a degraded mirror otherwise burns the whole job and
|
||||
# reports "cancelled", which reads as our failure rather than apt's.
|
||||
for attempt in 1 2 3; do
|
||||
sudo apt-get update && break || sleep 15
|
||||
done
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
patchelf \
|
||||
xdg-utils
|
||||
# libappindicator3-dev, not the ayatana package: the .deb's generated
|
||||
# Depends: follows whichever one the binary linked against, so the
|
||||
# two must not be mixed between builds.
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-desktop/src-tauri -> target
|
||||
# Saving the Windows target directory costs more minutes than the
|
||||
# rebuild it saves.
|
||||
save-if: ${{ runner.os != 'Windows' }}
|
||||
|
||||
- name: Install the Tauri CLI
|
||||
shell: bash
|
||||
# `|| true` swallowed a genuine install failure and the next step then
|
||||
# died with an unrelated "no such command: tauri". It also behaved
|
||||
# differently per leg, since Windows defaults to pwsh where `true` is
|
||||
# not a command. Skip the install if it is already there; otherwise
|
||||
# let a failure be a failure.
|
||||
run: command -v cargo-tauri >/dev/null || cargo install tauri-cli --version '^2' --locked
|
||||
|
||||
- name: Set the desktop version from the release tag
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: node src/praisonai-desktop/tools/set-release-version.mjs "$RELEASE_TAG"
|
||||
|
||||
- name: Build the bundle
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: cargo tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: The Windows binary must carry the release version
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
$expected = $env:RELEASE_TAG -replace '^v', ''
|
||||
$numeric = ($expected -split '[-+]')[0] + '.0'
|
||||
$version = (Get-Item "target/${{ matrix.target }}/release/praisonai-desktop.exe").VersionInfo
|
||||
if ($version.ProductVersion -ne $expected) {
|
||||
throw "ProductVersion $($version.ProductVersion) does not match $expected"
|
||||
}
|
||||
if ($version.FileVersion -ne $numeric) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $numeric"
|
||||
}
|
||||
|
||||
- name: The macOS bundle must carry a valid signature
|
||||
if: runner.os == 'macOS'
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dmg="$(find "target/${{ matrix.target }}/release/bundle" -name '*.dmg' -print -quit)"
|
||||
[ -n "$dmg" ] || { echo "no .dmg was produced"; exit 1; }
|
||||
|
||||
# Verify inside the DMG, not the .app beside it.
|
||||
#
|
||||
# Tauri deletes the app bundle once the DMG is built -- the build log
|
||||
# says so plainly ("Cleaning .../bundle/macos/PraisonAI.app") -- so a
|
||||
# gate that looked for the .app found nothing and failed every macOS
|
||||
# leg. Reading the DMG is also the better check: it is the artifact
|
||||
# people download, and the one that was broken.
|
||||
#
|
||||
# v4.7.2 shipped a DMG that macOS called "damaged and can't be
|
||||
# opened" on every Mac. The cause was not the missing Developer ID:
|
||||
# the only signature was the one rustc's linker puts on the bare
|
||||
# executable, whose CodeDirectory declares a resource seal that an
|
||||
# unsigned bundle does not have --
|
||||
#
|
||||
# Sealed Resources=none
|
||||
# code has no resources but signature indicates they must be present
|
||||
#
|
||||
# That is an *invalid* signature, not an unsigned one, and macOS
|
||||
# reports invalid as "damaged". Right-click -> Open cannot bypass it,
|
||||
# so the instructions shipped alongside it could not work either.
|
||||
mnt="$(mktemp -d)"
|
||||
hdiutil attach -nobrowse -readonly -quiet -mountpoint "$mnt" "$dmg"
|
||||
trap 'hdiutil detach "$mnt" -quiet || true' EXIT
|
||||
|
||||
app="$(find "$mnt" -maxdepth 1 -name '*.app' -print -quit)"
|
||||
[ -n "$app" ] || { echo "the DMG contains no .app"; ls -la "$mnt"; exit 1; }
|
||||
|
||||
echo "verifying $app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
# Printed, not asserted: --verify already fails on the exact bundle
|
||||
# that shipped, so a second check for the same thing would only be a
|
||||
# second way to say it. This is here to make the log readable.
|
||||
codesign -dv --verbose=2 "$app" 2>&1 | grep -E 'Sealed Resources|flags=' || true
|
||||
|
||||
- name: Name the artifact for the platform it runs on
|
||||
id: artifact
|
||||
shell: bash
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
root="target/${{ matrix.target }}/release/bundle"
|
||||
found="$(find "$root" -name "*.${{ matrix.ext }}" -print -quit || true)"
|
||||
# Fail loudly rather than uploading nothing: an empty release asset
|
||||
# list is exactly the state this workflow exists to fix, and a bundler
|
||||
# given a target it does not support exits zero having written none.
|
||||
if [ -z "$found" ]; then
|
||||
echo "no .${{ matrix.ext }} was produced under $root"
|
||||
find "$root" -type f -print || echo "(the bundle directory does not exist)"
|
||||
exit 1
|
||||
fi
|
||||
out="PraisonAI-${tag}-${{ matrix.asset }}.${{ matrix.ext }}"
|
||||
mv "$found" "../$out"
|
||||
echo "path=src/praisonai-desktop/$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach it to the release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload \
|
||||
"${{ github.event.release.tag_name || inputs.tag }}" \
|
||||
"${{ steps.artifact.outputs.path }}" --clobber
|
||||
|
||||
verify:
|
||||
# A release with a missing platform is worse than an obviously failed
|
||||
# build, because nobody notices. This says which one is absent.
|
||||
needs: build
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check every platform produced an asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets -q '.assets[].name')"
|
||||
echo "$assets"
|
||||
missing=""
|
||||
for want in macos-apple-silicon macos-intel windows-x64-setup linux-x64; do
|
||||
echo "$assets" | grep -q -- "$want" || missing="$missing $want"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::the release is missing:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "all four platform builds are attached"
|
||||
@@ -1,115 +0,0 @@
|
||||
# The desktop app's tests.
|
||||
#
|
||||
# These existed and ran nowhere: no workflow referenced praisonai-desktop, and
|
||||
# the frontend suite could not even be installed -- jsdom was undeclared, so it
|
||||
# ran only on machines that happened to have one further up the tree, at
|
||||
# whatever version that happened to be.
|
||||
name: desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
|
||||
'.github/workflows/desktop-release.yml']
|
||||
pull_request:
|
||||
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
|
||||
'.github/workflows/desktop-release.yml']
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-desktop
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
name: engine (${{ matrix.os }})
|
||||
# The engine is the part that has to behave the same on all three, which is
|
||||
# the entire point of the portability suite -- so it is run on all three.
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run the engine tests
|
||||
shell: bash
|
||||
env:
|
||||
# Never touch a real keychain on a shared runner.
|
||||
PRAISONAI_KEYCHAIN_SERVICE: ai.praison.desktop.ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for suite in engine/test_*.py; do
|
||||
echo "::group::$suite"
|
||||
python "$suite" -v || failed=1
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $failed
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
# `test:ci` is every suite except lifecycle, which drives real
|
||||
# conversations against a live engine and a real model -- neither of
|
||||
# which exists on a runner. It is excluded by name rather than left to
|
||||
# skip quietly, so that a skipped test here is a genuine problem.
|
||||
- name: Run the UI tests
|
||||
# A suite that skips everything still exits 0 -- that is how thirteen
|
||||
# tests came to look like a pass while proving nothing. So the skip
|
||||
# count is asserted, not just the exit status.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -o pipefail
|
||||
npm run test:ci 2>&1 | tee "$RUNNER_TEMP/ui.log"
|
||||
skipped=$(grep -E '^. skipped ' "$RUNNER_TEMP/ui.log" | grep -oE '[0-9]+$' | head -1)
|
||||
echo "skipped: ${skipped:-unknown}"
|
||||
test "${skipped:-1}" -eq 0
|
||||
|
||||
shell:
|
||||
name: shell (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15, windows-latest]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-desktop/src-tauri
|
||||
# The Windows cache is large and slow enough to cost more than it saves.
|
||||
save-if: ${{ runner.os != 'Windows' }}
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libxdo-dev libssl-dev patchelf
|
||||
- name: Test
|
||||
# Debug info is the bulk of a Tauri target directory and nothing here
|
||||
# reads a backtrace, so building without it keeps the runner's disk
|
||||
# from filling on the Windows leg.
|
||||
run: cargo test --config 'profile.test.debug=false' --config 'profile.dev.debug=false'
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -1,148 +0,0 @@
|
||||
# The mobile package's tests.
|
||||
#
|
||||
# Written at the same time as the package, deliberately. praisonai-desktop's
|
||||
# suite existed and ran nowhere for months because no workflow referenced it,
|
||||
# and a package whose tests never run in CI is a package whose tests are
|
||||
# decoration -- the 523 here would go stale silently.
|
||||
#
|
||||
# Four gates, and they catch different things:
|
||||
#
|
||||
# typecheck -- the boundary between erasable and non-erasable TypeScript.
|
||||
# node:test strips types rather than compiling, so an enum or
|
||||
# a constructor parameter property is a runtime parse error at
|
||||
# import; `erasableSyntaxOnly` turns that into a typecheck
|
||||
# failure at the file that caused it.
|
||||
# boundaries -- the two seams. Only engines/src/praisonai-ts may import
|
||||
# 'praisonai'; only adapters/src/tauri may import @tauri-apps.
|
||||
# These are the whole reason the package is swappable, and a
|
||||
# rule enforced only by review stops being enforced.
|
||||
# test -- the suite itself.
|
||||
# bundle gate -- webview compatibility. This is the one a normal test run
|
||||
# CANNOT do: node:test runs in Node, where a `crypto` import
|
||||
# and a top-level `process.env` both work fine, and both kill
|
||||
# a webview bundle at import time.
|
||||
name: mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
pull_request:
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
|
||||
concurrency:
|
||||
group: mobile-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: check (node ${{ matrix.node }})
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 22.18 is the floor declared in package.json -- tested because a
|
||||
# declared floor nobody runs is a guess. 24 is the forward check:
|
||||
# native type-stripping is the feature this package depends on most,
|
||||
# and it is exactly the kind of thing a major bump changes.
|
||||
node: ['22.18', '24']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Layer boundaries
|
||||
run: npm run boundaries
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Bundle gate fixtures
|
||||
# Runs the gate's own suite, which proves each check can FAIL -- a
|
||||
# build check that has only ever seen clean input is a check nobody
|
||||
# knows the state of.
|
||||
run: npm run test:bundle
|
||||
|
||||
upstream-parity:
|
||||
name: upstream parity
|
||||
# Its own job so drift from the real Agent is visible in the checks list
|
||||
# rather than buried in another job's log.
|
||||
#
|
||||
# It needs only THIS package's install. praisonai-ts's own dependencies are
|
||||
# neither needed nor installable -- `npm install` there fails with an
|
||||
# ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk) -- and
|
||||
# every member the check looks at is declared locally in simple.ts. That is
|
||||
# verified rather than assumed: with praisonai-ts's node_modules moved
|
||||
# aside, injected drift is still caught as TS2741.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Does the real Agent still satisfy PraisonAgent?
|
||||
# Skips loudly if the praisonai-ts checkout is missing, rather than
|
||||
# passing quietly -- a check that silently stops covering anything is
|
||||
# worse than no check.
|
||||
run: npm run check:upstream
|
||||
|
||||
shell:
|
||||
name: rust shell (${{ matrix.os }})
|
||||
# The Rust crate had 14 tests and no job running them -- the same failure
|
||||
# this workflow's header says it was created to prevent, one language over.
|
||||
#
|
||||
# Both platforms, because the crate is cfg-heavy: the back-gesture fallback
|
||||
# is #[cfg(target_os = "android")] / ios / not(mobile), and a cfg mistake
|
||||
# compiles perfectly on whichever host you happened to try.
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
# generate_context! expands frontendDist (../dist) at COMPILE time, and
|
||||
# dist/ is gitignored -- absent on a fresh checkout. Without this the
|
||||
# crate cannot compile and neither test nor clippy below ever runs.
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
# The pure-logic tests: back-gesture arbitration, the lifecycle mapping,
|
||||
# and the event-name contract. None of them needs a phone.
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -1,62 +0,0 @@
|
||||
# Can praisonai-ts still be loaded in a webview?
|
||||
#
|
||||
# Issue #4437 was this failing: `crypto` and `events` were static imports on
|
||||
# the Agent graph, so a webview bundle died at IMPORT time with no error
|
||||
# boundary and a blank screen. PR #4438 fixed it, and nothing stopped it
|
||||
# coming back.
|
||||
#
|
||||
# `npm test` cannot catch a regression here, structurally: it runs in Node,
|
||||
# where every one of these imports works perfectly. One `import { x } from
|
||||
# "fs"` added to a file the Agent happens to reach would silently un-ship the
|
||||
# mobile app, and the first signal would be a blank screen on a device.
|
||||
#
|
||||
# Deliberately narrow. It runs only when praisonai-ts changes, installs only
|
||||
# what it needs, and asserts one thing.
|
||||
name: praisonai-ts webview
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/scripts/webview-gate.mjs'
|
||||
- '.github/workflows/praisonai-ts-webview.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/scripts/webview-gate.mjs'
|
||||
- '.github/workflows/praisonai-ts-webview.yml'
|
||||
|
||||
concurrency:
|
||||
group: praisonai-ts-webview-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
webview:
|
||||
name: loadable in a webview
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-ts
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
# `npm install`, not `npm ci`: praisonai-ts gitignores its lockfile
|
||||
# (src/praisonai-ts/.gitignore), so `npm ci` has nothing to install from.
|
||||
# `--legacy-peer-deps` because the dependency tree does not otherwise
|
||||
# resolve -- bedrock-agentcore wants a peer of @strands-agents/sdk that
|
||||
# is not installed. That is pre-existing and out of scope here; without
|
||||
# the flag this job fails on something unrelated to what it checks.
|
||||
- name: Install
|
||||
run: npm install --no-audit --no-fund --legacy-peer-deps
|
||||
# Build first: the gate must see what actually SHIPS. The esm-shim adds
|
||||
# a CJS banner that does not exist in the TypeScript sources, and that
|
||||
# banner is what broke the published entry while the sources passed.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check
|
||||
run: npm run check:webview
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Sync to praisonai-js
|
||||
|
||||
# Canonical TS source: src/praisonai-ts/ in this monorepo.
|
||||
# This workflow pushes the mirror to MervinPraison/praisonai-js (npm repo).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: 'Target branch on MervinPraison/praisonai-js'
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
mode:
|
||||
description: 'pr = open pull request (default); commit = push directly to target branch'
|
||||
required: false
|
||||
default: pr
|
||||
type: choice
|
||||
options:
|
||||
- pr
|
||||
- commit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
TARGET_REPO: MervinPraison/praisonai-js
|
||||
SOURCE_PATH: src/praisonai-ts
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
steps:
|
||||
- name: Validate token
|
||||
run: |
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "GH_TOKEN secret is required (PAT with repo write access to praisonai-js)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout PraisonAI monorepo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout praisonai-js
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.TARGET_REPO }}
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: praisonai-js-target
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
- name: Sync monorepo path into praisonai-js
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rsync -a --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'dist/' \
|
||||
--exclude 'coverage/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.tsbuildinfo' \
|
||||
"${SOURCE_PATH}/" praisonai-js-target/
|
||||
|
||||
- name: Push changes
|
||||
working-directory: praisonai-js-target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Preserve praisonai-js automation (rsync source has no .github/)
|
||||
if git diff --quiet; then
|
||||
echo "No changes — praisonai-js already matches ${SOURCE_PATH} on monorepo main."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
msg="sync: update from monorepo ${SOURCE_PATH}"
|
||||
|
||||
if [ "${{ inputs.mode }}" = "commit" ]; then
|
||||
git commit -m "$msg"
|
||||
git push origin "HEAD:${TARGET_BRANCH}"
|
||||
echo "Committed and pushed directly to ${TARGET_BRANCH} on praisonai-js."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
branch="sync/monorepo-praisonai-ts-$(date -u +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch"
|
||||
git commit -m "$msg"
|
||||
git push origin "$branch"
|
||||
|
||||
gh pr create \
|
||||
--repo "$TARGET_REPO" \
|
||||
--base "$TARGET_BRANCH" \
|
||||
--head "$branch" \
|
||||
--title "$msg" \
|
||||
--body "$(cat <<EOF
|
||||
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
|
||||
|
||||
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
|
||||
|
||||
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
|
||||
EOF
|
||||
)"
|
||||
echo "Opened PR on ${TARGET_REPO} (${branch} → ${TARGET_BRANCH})."
|
||||
@@ -101,9 +101,8 @@ jobs:
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
- shard: train
|
||||
workdir: src/praisonai-train
|
||||
# tests/unit/data/ (96 tests) was collected by no workflow at all.
|
||||
paths: >-
|
||||
tests/unit/train/ tests/unit/data/
|
||||
tests/unit/train/
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
|
||||
@@ -98,20 +98,6 @@ jobs:
|
||||
--ignore=tests/unit/cli/test_message_queue.py \
|
||||
--maxfail=5
|
||||
|
||||
- name: Run praisonai-agents subscription-auth smoke tests
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: smoke
|
||||
PRAISONAI_ALLOW_NETWORK: '0'
|
||||
OPENAI_MODEL_NAME: gpt-4o-mini
|
||||
OPENAI_API_KEY: 'test-key'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai-agents
|
||||
python -m pytest tests/smoke/test_subscription_auth.py \
|
||||
tests/unit/auth/test_agent_auth_wiring.py \
|
||||
-q --tb=line --timeout=60 --maxfail=5
|
||||
|
||||
- name: Standalone praisonai-code import smoke
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -7,22 +7,9 @@ You are working on the PraisonAI project.
|
||||
- Be concise and helpful in responses
|
||||
- Test implementation thoroughly
|
||||
- Ensure backward compatibility with existing APIs
|
||||
- Follow protocol-driven design across the nine Python PyPI packages plus the TypeScript SDK: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper, TypeScript/JavaScript SDK in `src/praisonai-ts/` (`npm: praisonai`)
|
||||
- **TypeScript review routing:** For TS/JS issues and PRs, read [`src/praisonai-ts/AGENTS.md`](src/praisonai-ts/AGENTS.md) (not just this file). Canonical source is `src/praisonai-ts/` in this monorepo; [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) is the npm mirror only (sync: monorepo → praisonai-js via workflow **Sync to praisonai-js**). Do not implement TS fixes in praisonai-js for PraisonAI issues.
|
||||
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
|
||||
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
|
||||
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
|
||||
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
|
||||
- When reviewing a PR or an issue, evaluate whether the change addresses a framework concern or a user goal, and design its surface (params, naming, defaults) accordingly
|
||||
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
|
||||
|
||||
## Issue and PR routing
|
||||
|
||||
| Area | Where to implement | AGENTS.md to read | Tests |
|
||||
|------|-------------------|-------------------|-------|
|
||||
| Python core SDK | `src/praisonai-agents/praisonaiagents/` | `src/praisonai-agents/AGENTS.md` | `pytest` in `src/praisonai-agents/tests/` |
|
||||
| Python wrapper / CLI | `src/praisonai/`, `src/praisonai-code/`, etc. | `src/praisonai-agents/AGENTS.md` | `pytest` under `src/praisonai/tests/` |
|
||||
| **TypeScript / JavaScript SDK** | **`src/praisonai-ts/`** | **`src/praisonai-ts/AGENTS.md`** | `cd src/praisonai-ts && npm run build && npm test` |
|
||||
| Agent-callable tools | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) | — | repo tests |
|
||||
| Lifecycle plugins | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) | — | repo tests |
|
||||
| Documentation | [PraisonAIDocs](https://github.com/MervinPraison/PraisonAIDocs) | — | `nav-check` |
|
||||
| npm mirror (read-only for fixes) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) | mirror of `src/praisonai-ts/AGENTS.md` | CI on praisonai-js |
|
||||
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
|
||||
@@ -7,20 +7,8 @@ Thank you for your interest! Here's how to get started:
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
## Where to report issues
|
||||
|
||||
| Topic | Report here | Where fixes land |
|
||||
|-------|-------------|------------------|
|
||||
| Python SDK, CLI, wrapper | **This repo** | `src/praisonai-agents/`, `src/praisonai/` |
|
||||
| TypeScript / JavaScript (`npm install praisonai`) | **This repo** | `src/praisonai-ts/` |
|
||||
| Cross-language parity | **This repo** | Often both Python and TypeScript paths |
|
||||
| Agent-callable tools | **This repo** | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) |
|
||||
| Lifecycle plugins (tracing, hooks, guardrails) | **This repo** | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) |
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow existing code style
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
- TypeScript changes: implement in `src/praisonai-ts/` and run `npm test` there
|
||||
- After merge, sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`
|
||||
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
|
||||
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
|
||||
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
|
||||
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
|
||||
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
|
||||
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
|
||||
|
||||
### Layer 1 · Prompt — *Did I say it clearly?*
|
||||
|
||||
@@ -235,7 +235,7 @@ The harness is commoditising; **where** the agent executes is the next multiplie
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
|
||||
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
@@ -243,7 +243,7 @@ from praisonaiagents import Agent, AgentFlow
|
||||
writer = Agent(name="Writer", instructions="You write files.")
|
||||
reader = Agent(name="Reader", instructions="You read files.")
|
||||
|
||||
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
|
||||
flow = AgentFlow(run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
|
||||
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
|
||||
```
|
||||
|
||||
@@ -251,7 +251,7 @@ Same thing with no Python at all:
|
||||
|
||||
```yaml
|
||||
name: remote-demo
|
||||
tools_run_on: docker # every step shares one sandbox
|
||||
run_on: docker # every step shares one sandbox
|
||||
agents:
|
||||
writer: {role: Writer, goal: Write files}
|
||||
reader: {role: Reader, goal: Read files}
|
||||
@@ -262,51 +262,22 @@ steps:
|
||||
action: "Read /workspace/note.txt"
|
||||
```
|
||||
|
||||
For a single agent, two words cover it — and they answer different questions:
|
||||
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
|
||||
|
||||
# A. Only the TOOLS move. Thinking stays on your machine.
|
||||
agent = Agent(name="builder", instructions="You build things.",
|
||||
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
|
||||
# tenki | sandlock | ssh | novita | subprocess
|
||||
# A. Tools run in a remote sandbox; the agent loop stays local
|
||||
agent = Agent(name="builder", backend=LocalAgent(
|
||||
compute="e2b", # or modal | daytona | flyio | docker | tenki
|
||||
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
|
||||
))
|
||||
|
||||
# B. The WHOLE agent moves — model calls, loop and tools
|
||||
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
|
||||
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
|
||||
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
|
||||
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
|
||||
agent.start("Write a Python script that prints the first 10 primes, then run it")
|
||||
```
|
||||
|
||||
Ask any object where it runs, and it will tell you:
|
||||
|
||||
```python
|
||||
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
|
||||
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
|
||||
|
||||
>>> agent.where_does_it_run()
|
||||
Thinking (the AI model calls) happens on this machine.
|
||||
Tools run on a Docker container.
|
||||
Your own tools (check_db) still run on this machine -- only shell, file and
|
||||
code tools move. They read and write this machine's files.
|
||||
```
|
||||
|
||||
Naming a place that cannot do the job is a typo, not a preference, so it says so:
|
||||
|
||||
```python
|
||||
>>> Agent(name="x", instructions="i", run_on="e2b")
|
||||
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
|
||||
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
|
||||
commands but cannot host an agent loop.
|
||||
To run only the tools there: Agent(tools_run_on='e2b')
|
||||
```
|
||||
|
||||
To run one block of code somewhere else, name the place on that call:
|
||||
|
||||
```python
|
||||
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
|
||||
```
|
||||
|
||||
See what is running and reclaim strays:
|
||||
|
||||
```bash
|
||||
|
||||
+1
-8
@@ -8,18 +8,11 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|
||||
|---------|----------|-------------|
|
||||
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
|
||||
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
|
||||
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
|
||||
|
||||
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
|
||||
|
||||
### Packages maintained in their own repositories
|
||||
|
||||
`praisonai-platform` now lives at
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
Report platform vulnerabilities through
|
||||
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
|
||||
not here — it is versioned and released independently of the SDK.
|
||||
|
||||
## In scope
|
||||
|
||||
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
|
||||
|
||||
@@ -30,7 +30,6 @@ Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
@@ -68,12 +67,9 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>(value: Optional[str]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
|
||||
@@ -242,12 +238,10 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
|
||||
@@ -257,7 +251,6 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_context</a>(query: Optional[str] = None, **kwargs) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
@@ -751,7 +744,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
|
||||
@@ -885,7 +878,6 @@ Methods:
|
||||
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
@@ -902,12 +894,7 @@ Methods:
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule run <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
@@ -916,7 +903,6 @@ Methods:
|
||||
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
|
||||
@@ -926,7 +912,7 @@ Methods:
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { AgentEvent, AgentMessage, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason } from "./agent";
|
||||
export type { AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent } from "./agent/context";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Deployment infra has moved
|
||||
|
||||
Deployment assets are no longer at the monorepo root.
|
||||
|
||||
| Asset | New location |
|
||||
|-------|----------------|
|
||||
| Gateway Helm chart | [`src/praisonai-bot/infra/helm/praisonai-gateway/`](../src/praisonai-bot/infra/helm/praisonai-gateway/) |
|
||||
| Agents API Helm, Compose, Starters | [`src/praisonai-deploy/infra/`](../src/praisonai-deploy/infra/) |
|
||||
|
||||
See [`src/praisonai-deploy/infra/README.md`](../src/praisonai-deploy/infra/README.md).
|
||||
@@ -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.7.4" \
|
||||
"praisonai>=4.6.164" \
|
||||
"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.7.4" \
|
||||
"praisonai>=4.6.164" \
|
||||
"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.7.4" \
|
||||
"praisonai>=4.6.164" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -178,19 +178,19 @@ def example_url_parsing():
|
||||
print("\n=== Example 9: URL Parsing ===")
|
||||
|
||||
# Memory supports URL connection strings
|
||||
# Supported schemes: sqlite://, mongodb://, mongodb+srv://
|
||||
# Supported: postgresql://, redis://, sqlite:///, mongodb://
|
||||
agent = Agent(
|
||||
instructions="You are a database-backed assistant",
|
||||
memory="mongodb://user:password@localhost:27017/praisonai",
|
||||
memory="postgresql://postgres:password@localhost:5432/praisonai",
|
||||
)
|
||||
print("Memory URL parsed: memory enabled")
|
||||
|
||||
# Also works with presets
|
||||
agent2 = Agent(
|
||||
instructions="You are a sqlite-backed assistant",
|
||||
memory="sqlite", # Preset name
|
||||
instructions="You are a redis-backed assistant",
|
||||
memory="redis", # Preset name
|
||||
)
|
||||
print("Memory preset 'sqlite': configured")
|
||||
print("Memory preset 'redis': configured")
|
||||
|
||||
|
||||
def example_knowledge_sources():
|
||||
|
||||
@@ -8,15 +8,15 @@ agent = Agent(
|
||||
)
|
||||
|
||||
# With preset
|
||||
agent_sqlite = Agent(
|
||||
agent_redis = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
memory="sqlite", # Uses sqlite preset
|
||||
memory="redis", # Uses redis preset
|
||||
)
|
||||
|
||||
# With URL
|
||||
agent_mongo = Agent(
|
||||
agent_postgres = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
memory="mongodb://localhost/mydb", # URL auto-detected
|
||||
memory="postgresql://localhost/mydb", # URL auto-detected
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
# MCP Server Examples
|
||||
|
||||
## Install (SHA-256)
|
||||
|
||||
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
|
||||
|
||||
```
|
||||
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
|
||||
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
|
||||
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
|
||||
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
|
||||
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
|
||||
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
VER=v0.6.0
|
||||
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
|
||||
# swap darwin-arm64 for your OS/arch
|
||||
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
|
||||
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
|
||||
shasum -a 256 -c SHA256SUMS --ignore-missing
|
||||
gbr-agent pair && gbr-agent run
|
||||
```
|
||||
|
||||
|
||||
Examples demonstrating the MCP Server v2 features per MCP Protocol Version 2025-11-25.
|
||||
|
||||
## Features Demonstrated
|
||||
@@ -157,19 +130,3 @@ These examples comply with MCP Protocol Version 2025-11-25:
|
||||
- `destructiveHint`: true
|
||||
- `idempotentHint`: false
|
||||
- `openWorldHint`: true
|
||||
|
||||
|
||||
## Build Remote Agent (`gbr_pair.py`)
|
||||
|
||||
Pair a phone running [Build Remote Agent](https://grokbuildremote.com/) to a PraisonAI agent via MCP stdio `gbr-mcp` (protocol `gbr/1`). Requires `gbr-agent pair && gbr-agent run`. Phone is spectator. Never put mailbox keys in the example.
|
||||
|
||||
```bash
|
||||
python gbr_pair.py
|
||||
```
|
||||
|
||||
## What the phone sees
|
||||
|
||||
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
|
||||
https://grokbuildremote.com/integrations.html
|
||||
@@ -1,76 +0,0 @@
|
||||
"""
|
||||
Build Remote Agent pairing example.
|
||||
|
||||
Pair a phone (Build Remote Agent) to this PraisonAI session through the
|
||||
free MIT gbr-agent. Protocol gbr/1. Phone is spectator + veto.
|
||||
|
||||
Requirements:
|
||||
- gbr-agent v0.6.0+ (`gbr-agent pair && gbr-agent run`)
|
||||
- GrokBuildRemote-Agents cloned; `npm install` in mcp/gbr-mcp
|
||||
- pip install praisonaiagents[mcp]
|
||||
|
||||
Usage:
|
||||
python gbr_pair.py
|
||||
|
||||
Never put mailbox keys in this file. Phone Settings → Bot API is the
|
||||
only place the relay key is copied.
|
||||
|
||||
Independent product by Linespotting AB. Not affiliated with xAI or SpaceX.
|
||||
https://grokbuildremote.com/
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git
|
||||
|
||||
## Install (SHA-256)
|
||||
|
||||
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
|
||||
|
||||
```
|
||||
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
|
||||
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
|
||||
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
|
||||
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
|
||||
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
|
||||
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
VER=v0.6.0
|
||||
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
|
||||
# swap darwin-arm64 for your OS/arch
|
||||
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
|
||||
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
|
||||
shasum -a 256 -c SHA256SUMS --ignore-missing
|
||||
gbr-agent pair && gbr-agent run
|
||||
```
|
||||
|
||||
# then point args at mcp/gbr-mcp/bin/gbr-mcp.js (loopback stdio).
|
||||
gbr = Agent(
|
||||
instructions=(
|
||||
"You can attach a phone spectator via Build Remote Agent. "
|
||||
"Use GBR tools only to diagnose or attach the local Bot API "
|
||||
"at 127.0.0.1:8788. Do not request mailbox keys."
|
||||
),
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP(
|
||||
"node",
|
||||
args=["GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js"],
|
||||
),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
gbr.start(
|
||||
"Check Build Remote Agent health on 127.0.0.1:8788 and list sessions."
|
||||
)
|
||||
|
||||
## What the phone sees
|
||||
|
||||
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
|
||||
https://grokbuildremote.com/integrations.html
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
HOL Guard Tool Policy Example - PraisonAI Agents
|
||||
|
||||
Demonstrates using HOL Guard (an optional, external runtime security engine)
|
||||
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
|
||||
tool calls *before* they execute.
|
||||
|
||||
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
|
||||
classify a shell command before execution:
|
||||
|
||||
hol-guard command test '<command>' --json
|
||||
|
||||
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
|
||||
allow/benign classification. Review/block/unknown verdicts, malformed output,
|
||||
timeouts, or CLI failures all short-circuit the call so the tool never runs.
|
||||
|
||||
pip install hol-guard # optional, external dependency
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
|
||||
|
||||
|
||||
COMMAND_BEARING_TOOLS = {"run_shell"}
|
||||
GUARD_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
@tool
|
||||
def run_shell(command: str) -> str:
|
||||
"""Run a shell command and return its output."""
|
||||
result = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout or result.stderr
|
||||
|
||||
|
||||
def _inspect_with_hol_guard(command: str) -> bool:
|
||||
"""Return True only if HOL Guard explicitly allows the command.
|
||||
|
||||
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
|
||||
timeout, malformed JSON, or a non-allow verdict.
|
||||
"""
|
||||
if not shutil.which("hol-guard"):
|
||||
return False
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["hol-guard", "command", "test", command, "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=GUARD_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
if completed.returncode != 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
verdict = json.loads(completed.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
if not isinstance(verdict, dict):
|
||||
return False
|
||||
|
||||
decision = str(verdict.get("decision", "")).lower()
|
||||
return decision in ("allow", "benign")
|
||||
|
||||
|
||||
@wrap_tool_call
|
||||
def hol_guard_tool_policy(request: ToolRequest, call_next):
|
||||
"""Gate command-bearing tools through HOL Guard before execution."""
|
||||
if request.tool_name not in COMMAND_BEARING_TOOLS:
|
||||
return call_next(request)
|
||||
|
||||
command = request.arguments.get("command", "")
|
||||
|
||||
if _inspect_with_hol_guard(command):
|
||||
return call_next(request)
|
||||
|
||||
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
|
||||
# Surface the denial in `result` too: the agent tool executor returns
|
||||
# `ToolResponse.result` to the model and discards `error`, so a denial with
|
||||
# only `error` set would reach the model as a null result. Keeping `error`
|
||||
# populated preserves the signal for after_tool hooks and logging.
|
||||
return ToolResponse(
|
||||
tool_name=request.tool_name,
|
||||
result=blocked_message,
|
||||
error=blocked_message,
|
||||
context=request.context,
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="GuardedShellBot",
|
||||
instructions="You run shell commands only when they are safe.",
|
||||
tools=[run_shell],
|
||||
hooks=[hol_guard_tool_policy],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("HOL Guard tool policy registered on 'run_shell'.")
|
||||
print("Commands are only executed when Guard returns an explicit allow.")
|
||||
print("\n✓ HOL Guard example complete")
|
||||
+11
-12
@@ -1,18 +1,16 @@
|
||||
"""
|
||||
Chroma Knowledge Store - Agent-First Example
|
||||
Qdrant Knowledge Store - Agent-First Example
|
||||
|
||||
Demonstrates using Chroma (a shipped vector store) for knowledge-based RAG
|
||||
with an Agent.
|
||||
|
||||
To use a provider that is not shipped (e.g. Qdrant), register an adapter
|
||||
first with ``register_knowledge_adapter("qdrant", MyQdrantAdapter)`` and then
|
||||
pass ``"provider": "qdrant"``.
|
||||
Demonstrates using Qdrant for knowledge-based RAG with an Agent.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
|
||||
Run:
|
||||
python knowledge_chroma.py
|
||||
python knowledge_qdrant.py
|
||||
|
||||
Expected Output:
|
||||
Agent responds using knowledge from documents
|
||||
@@ -46,16 +44,17 @@ doc_path = os.path.join(temp_dir, "guide.txt")
|
||||
with open(doc_path, "w") as f:
|
||||
f.write(sample_doc)
|
||||
|
||||
print("=== Chroma Knowledge Store Demo (Agent-First) ===")
|
||||
print("=== Qdrant Knowledge Store Demo (Agent-First) ===")
|
||||
|
||||
# Agent-first approach: use knowledge parameter with a shipped vector store
|
||||
# Agent-first approach: use knowledge parameter with Qdrant
|
||||
agent = Agent(
|
||||
name="KnowledgeAssistant",
|
||||
instructions="You are a helpful assistant with access to technical documentation.",
|
||||
knowledge={
|
||||
"sources": [doc_path],
|
||||
"vector_store": {
|
||||
"provider": "chroma",
|
||||
"provider": "qdrant",
|
||||
"url": "http://localhost:6333",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -71,4 +70,4 @@ print("\n=== Demo Complete ===")
|
||||
|
||||
# --- Advanced: Direct Store Usage ---
|
||||
# from praisonai.persistence.factory import create_knowledge_store
|
||||
# store = create_knowledge_store("chroma")
|
||||
# store = create_knowledge_store("qdrant", url="http://localhost:6333")
|
||||
@@ -14,7 +14,7 @@ Expected Output:
|
||||
Agent responds with state persisted to Redis
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.db import db
|
||||
|
||||
print("=== Redis State Store (Agent-First) ===")
|
||||
@@ -28,7 +28,8 @@ my_db = db(
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant.",
|
||||
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
|
||||
memory=my_db,
|
||||
session_id="redis-state-example"
|
||||
)
|
||||
|
||||
# Chat - state is automatically managed via Redis
|
||||
|
||||
@@ -23,7 +23,6 @@ from praisonaiagents.hooks.verification import (
|
||||
VerificationResult,
|
||||
BaseVerificationHook,
|
||||
CommandVerificationHook,
|
||||
FileCheckHook,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,19 +100,7 @@ def main():
|
||||
print(f" Name: {cmd_hook.name}")
|
||||
print(f" Command: {cmd_hook.command}")
|
||||
print(f" Timeout: {cmd_hook.timeout_seconds}s")
|
||||
|
||||
# 6. FileCheckHook: declarative, no-shell completion gate
|
||||
print("\n6. FileCheckHook (declarative completion gate):")
|
||||
file_hook = FileCheckHook(
|
||||
name="changelog", path="CHANGELOG.md", non_empty=True
|
||||
)
|
||||
file_result = file_hook.run()
|
||||
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
|
||||
print(
|
||||
" Gate wiring: when verification_hooks are configured, the autonomous "
|
||||
"loop cannot finalise with a success outcome while a blocking hook fails."
|
||||
)
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ Verification hooks example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Basic example of using EvoLink.AI with PraisonAI.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
api_key = os.getenv("EVOLINK_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"EVOLINK_API_KEY is not set. Set it before running this example: "
|
||||
"export EVOLINK_API_KEY=your_api_key"
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
llm={
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": api_key,
|
||||
"base_url": "https://direct.evolink.ai/v1",
|
||||
},
|
||||
)
|
||||
|
||||
response = agent.start("Hello! Share one practical AI automation idea.")
|
||||
print(response)
|
||||
|
||||
coding_task = """
|
||||
Write a Python function that validates whether a string is a palindrome.
|
||||
Include a short docstring and ignore spaces and capitalization.
|
||||
"""
|
||||
|
||||
response = agent.start(coding_task)
|
||||
print(response)
|
||||
@@ -15,4 +15,4 @@ if completed.status == "completed":
|
||||
# List all videos
|
||||
videos = agent.list()
|
||||
|
||||
# Models: openai/sora-2, gemini/veo-3.1-generate-preview, azure/sora-2
|
||||
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
|
||||
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
|
||||
| `praisonai serve unified --port 8765` | Start unified server |
|
||||
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
|
||||
| `praisonai serve recipe --port 8765` | Start recipe server |
|
||||
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
|
||||
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
|
||||
| `praisonai serve tools --port 8081` | Start tools MCP server |
|
||||
| `praisonai serve a2a --port 8082` | Start A2A server |
|
||||
| `praisonai serve a2u --port 8083` | Start A2U server |
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
python mcp_http_server.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
praisonai serve mcp --transport http --port 8080
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8080/mcp/tools
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
"""Qdrant Vector Store - Agent-First Example
|
||||
"""Qdrant Vector Store - Agent-First Example (requires Docker)
|
||||
|
||||
Qdrant is not a shipped adapter. Register one first, then use it via the
|
||||
``knowledge`` parameter. If no adapter is registered this example skips
|
||||
cleanly instead of crashing.
|
||||
|
||||
from praisonaiagents.knowledge.adapters import register_knowledge_adapter
|
||||
register_knowledge_adapter("qdrant", MyQdrantAdapter)
|
||||
Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.knowledge.adapters import list_knowledge_adapters
|
||||
|
||||
if "qdrant" not in list_knowledge_adapters():
|
||||
print("SKIPPED: Qdrant - no 'qdrant' adapter registered")
|
||||
sys.exit(0)
|
||||
# Agent-first approach: use knowledge parameter with Qdrant
|
||||
url = os.getenv("QDRANT_URL", "http://localhost:6333")
|
||||
|
||||
# Agent-first approach: use knowledge parameter with a registered Qdrant adapter
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant with access to documents.",
|
||||
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant"}}
|
||||
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant", "url": url}}
|
||||
)
|
||||
|
||||
# Chat - agent uses knowledge for RAG
|
||||
@@ -27,3 +19,7 @@ response = agent.chat("What information do you have?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
print("PASSED: Qdrant with Agent")
|
||||
|
||||
# --- Advanced: Direct Store Usage ---
|
||||
# from praisonai.persistence import create_knowledge_store
|
||||
# store = create_knowledge_store("qdrant", url=url)
|
||||
@@ -2,25 +2,20 @@
|
||||
#
|
||||
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
|
||||
#
|
||||
# `tools_run_on:` is the only line that differs from a normal local workflow.
|
||||
# Every step shares the same sandbox and working directory, so the file the
|
||||
# writer creates is there for the reader. Thinking and orchestration still run
|
||||
# on your machine, and the sandbox is torn down when the run finishes.
|
||||
# `run_on:` is the only line that differs from a normal local workflow. Every
|
||||
# step shares the same sandbox and /workspace, so the file the writer creates
|
||||
# is there for the reader. Orchestration still runs on your machine, and the
|
||||
# sandbox is torn down when the run finishes.
|
||||
#
|
||||
# Places: docker | e2b | modal | daytona | flyio | tenki | local
|
||||
# subprocess | sandlock | ssh | novita
|
||||
# Providers: docker | e2b | modal | daytona | flyio | tenki | local
|
||||
# - docker needs a local Docker daemon and nothing else
|
||||
# - e2b / modal / daytona / flyio need that provider's API key
|
||||
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
|
||||
# - subprocess is process separation only, NOT a security boundary
|
||||
#
|
||||
# `run_on:` is still accepted here as the old spelling for this key.
|
||||
#
|
||||
# See what is still running: praisonai managed ps
|
||||
# Reclaim strays: praisonai managed stop --all
|
||||
|
||||
name: remote-sandbox-demo
|
||||
tools_run_on: docker
|
||||
run_on: docker
|
||||
|
||||
agents:
|
||||
writer:
|
||||
|
||||
@@ -99,12 +99,13 @@ pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
```
|
||||
|
||||
13. **praisonai-platform** advisories are handled in a different repository:
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
It is not part of this monorepo and is not published from here. Triage, patch,
|
||||
release and advisory publication all happen in that repo, on its own semver line.
|
||||
Nothing in this repository imports it, so a platform advisory never requires an
|
||||
SDK release.
|
||||
13. Publish **praisonai-platform** (only when the advisory targets platform code):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-platform
|
||||
praisonai publish pypi
|
||||
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
|
||||
```
|
||||
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
|
||||
|
||||
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
|
||||
```bash
|
||||
@@ -112,8 +113,9 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
|
||||
npm version patch && npm publish
|
||||
```
|
||||
|
||||
15. Verify npm when applicable:
|
||||
15. Verify platform / npm when applicable:
|
||||
```bash
|
||||
pip index versions praisonai-platform | head -1
|
||||
npm view praisonai version
|
||||
```
|
||||
|
||||
@@ -137,7 +139,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
}
|
||||
EOF
|
||||
```
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<LAST_VULNERABLE_VERSION>`: version before the fix
|
||||
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
|
||||
- Always include credits for the reporter
|
||||
@@ -188,8 +190,8 @@ After validating each advisory in source code, assign **one** label:
|
||||
| Package | Version line | Notes |
|
||||
|---|---|---|
|
||||
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
|
||||
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
|
||||
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
|
||||
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
|
||||
|
||||
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
|
||||
|
||||
@@ -215,11 +217,9 @@ Close (or publish advisory with no code change only if already mitigated) when:
|
||||
|---|---|---|---|
|
||||
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
|
||||
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
|
||||
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
|
||||
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
|
||||
|
||||
`praisonai-platform` is **not audited from this repository** — it lives in
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
|
||||
## RESOURCES & DOCUMENTATION
|
||||
|
||||
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
|
||||
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
|
||||
- ALWAYS set patched_versions before publishing advisory
|
||||
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
|
||||
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
|
||||
- Platform-only: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
|
||||
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
|
||||
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
|
||||
- ALWAYS request CVE AFTER publishing advisory
|
||||
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
|
||||
|
||||
@@ -207,7 +207,6 @@ and features (capabilities, bots, framework adapters) — use
|
||||
| 2b | `praisonai-bot` | Bots, gateway, channel CLI, OS daemon |
|
||||
| 2c | `praisonai-train` | LLM fine-tuning (Unsloth), agent training, `train` CLI |
|
||||
| 3 | `praisonai` | `framework_adapters/`, capabilities, serve orchestration, dashboard |
|
||||
| 1-TS | `praisonai-ts` | TypeScript/JavaScript SDK (`npm: praisonai`); canonical path `src/praisonai-ts/` |
|
||||
|
||||
**Cross-tier rule:** `praisonai-code` must not declare a PyPI dependency on `praisonai`. Use
|
||||
[`praisonai_code._wrapper_bridge`](../praisonai-code/praisonai_code/_wrapper_bridge.py) for lazy wrapper access.
|
||||
@@ -223,17 +222,6 @@ from `praisonai_train.cli.commands.*` (`train`, C10); `_WRAPPER_RESIDENT_COMMAND
|
||||
|
||||
Full boundary doc: [`src/praisonai/tests/C7.1_BOUNDARIES.md`](../praisonai/tests/C7.1_BOUNDARIES.md).
|
||||
|
||||
### 2.5 TypeScript SDK (`praisonai-ts`)
|
||||
|
||||
Python guidelines in this file do **not** replace the TypeScript SDK guide. For any issue, PR, or review touching `src/praisonai-ts/`:
|
||||
|
||||
1. Read [`src/praisonai-ts/AGENTS.md`](../praisonai-ts/AGENTS.md) — architecture, parity, triage, and PR review rules.
|
||||
2. Implement fixes in **`src/praisonai-ts/`** in this monorepo (not in `praisonaiagents/`).
|
||||
3. Verify with `cd src/praisonai-ts && npm install && npm run build && npm test`.
|
||||
4. After merge, maintainers sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`.
|
||||
|
||||
[MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) is the public npm checkout; direction is **monorepo → praisonai-js** only. Claude merge gate and `@claude` review TS changes under `src/praisonai-ts/` using the TS AGENTS.md criteria (lightweight, type-safe, Python parity where applicable).
|
||||
|
||||
---
|
||||
|
||||
## 3. Core SDK Architecture (praisonaiagents)
|
||||
@@ -888,8 +876,6 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
|
||||
| CLI backends | `praisonai_code/cli_backends/` |
|
||||
| Back-compat shims | `praisonai/cli/_shim.py`, `praisonai/cli/main.py` |
|
||||
| C5 regression tests | `praisonai/tests/unit/test_c5_backward_compat.py` |
|
||||
| TypeScript SDK | `src/praisonai-ts/` — see `src/praisonai-ts/AGENTS.md` |
|
||||
| npm mirror (sync only) | [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1088,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
|
||||
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
|
||||
>
|
||||
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
|
||||
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
|
||||
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
|
||||
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
|
||||
>
|
||||
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
|
||||
|
||||
@@ -17,78 +17,78 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 54 | 17350 |
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 50 | 15813 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 7 | 3311 |
|
||||
| ✅ Approval | 1 | 5 | 2537 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3747 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 32 | 13622 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3274 |
|
||||
| ✅ Autonomy | 3 | 7 | 2756 |
|
||||
| ✅ Bots | 7 | 31 | 13366 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 122 | 37626 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ CLI | 5 | 121 | 36059 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3292 |
|
||||
| ✅ Context Management | 16 | 36 | 14071 |
|
||||
| ✅ Code Execution | 2 | 14 | 4583 |
|
||||
| ✅ Conditions | 1 | 3 | 1179 |
|
||||
| ✅ Configuration | 3 | 8 | 3047 |
|
||||
| ✅ Context Management | 16 | 34 | 13396 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Events | 1 | 2 | 767 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1562 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 83 | 30723 |
|
||||
| ✅ Guardrails | 4 | 4 | 2253 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4542 |
|
||||
| ✅ Gateway | 7 | 74 | 27180 |
|
||||
| ✅ Guardrails | 4 | 4 | 2025 |
|
||||
| ✅ Handoffs | 11 | 6 | 2563 |
|
||||
| ✅ Hooks | 2 | 9 | 4434 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 17 | 5226 |
|
||||
| ✅ LLM | 3 | 15 | 5395 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 62 | 14418 |
|
||||
| ✅ Memory | 6 | 18 | 7237 |
|
||||
| ✅ Knowledge | 4 | 13 | 4333 |
|
||||
| ✅ LLM | 3 | 15 | 5192 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ MCP | 1 | 61 | 13993 |
|
||||
| ✅ Memory | 6 | 18 | 7070 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Observability | 2 | 23 | 2814 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1273 |
|
||||
| ✅ Output | 3 | 5 | 1242 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7793 |
|
||||
| ✅ Planning | 6 | 6 | 1549 |
|
||||
| ✅ Plugins | 8 | 4 | 2858 |
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ Providers | 1 | 56 | 7743 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ RAG | 5 | 15 | 3003 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 732 |
|
||||
| ✅ Retrieval | 2 | 5 | 1042 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7644 |
|
||||
| ✅ Skills | 6 | 16 | 5903 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Sandbox | 5 | 7 | 3202 |
|
||||
| ✅ Security | 1 | 3 | 3332 |
|
||||
| ✅ Sessions | 4 | 18 | 7158 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 145 | 43981 |
|
||||
| ✅ Tools | 12 | 141 | 41246 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Vector Store | 1 | 12 | 1211 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
| ✅ Web | 3 | 9 | 2467 |
|
||||
| ✅ Workflows | 5 | 16 | 6593 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (43 docs, 7130 lines)
|
||||
- ℹ️ Documents (1 docs, 798 lines)
|
||||
- ℹ️ Database (42 docs, 6713 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -92,6 +92,20 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
|
||||
# Thread-safe cache for lazy-loaded values
|
||||
_lazy_cache = {}
|
||||
|
||||
# Backward compatibility: _get_lazy_cache function for tests
|
||||
import threading
|
||||
_lazy_cache_local = threading.local()
|
||||
|
||||
def _get_lazy_cache():
|
||||
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
|
||||
|
||||
Note: This is kept for backward compatibility with tests.
|
||||
The main lazy loading now uses the centralized _lazy.py utility.
|
||||
"""
|
||||
if not hasattr(_lazy_cache_local, 'cache'):
|
||||
_lazy_cache_local.cache = {}
|
||||
return _lazy_cache_local.cache
|
||||
|
||||
# ============================================================================
|
||||
# LAZY IMPORT MAPPING
|
||||
# ============================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
|
||||
"The `dict` method is deprecated; use `model_dump` instead",
|
||||
"Pydantic serializer warnings",
|
||||
"PydanticSerializationUnexpectedValue",
|
||||
"fields but got", # litellm/pydantic "Expected N fields but got M"
|
||||
"Expected", # Catches all "Expected N fields but got M" patterns
|
||||
"Expected `StreamingChoices` but got `Choices`",
|
||||
"serialized value may not be as expected",
|
||||
"Mixing V1 models and V2 models",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,8 +136,9 @@ class AudioAgent:
|
||||
- AudioConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "AudioAgent")
|
||||
# Handle model= alias
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "AudioAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -217,13 +217,6 @@ class AutonomyConfig:
|
||||
f"Invalid autonomy level: {level!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
|
||||
)
|
||||
verification_hooks = data.get("verification_hooks")
|
||||
# Config parity: accept declarative `verification:` entries and map
|
||||
# `command:`/`file:` shapes onto the two hook classes.
|
||||
declarative = data.get("verification")
|
||||
if declarative:
|
||||
built = _build_verification_hooks(declarative)
|
||||
verification_hooks = (verification_hooks or []) + built
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
level=level,
|
||||
@@ -234,7 +227,7 @@ class AutonomyConfig:
|
||||
observe=data.get("observe", False),
|
||||
completion_promise=data.get("completion_promise"),
|
||||
clear_context=data.get("clear_context", False),
|
||||
verification_hooks=verification_hooks,
|
||||
verification_hooks=data.get("verification_hooks"),
|
||||
track_changes=data.get("track_changes"),
|
||||
sandbox=data.get("sandbox"),
|
||||
snapshot_dir=data.get("snapshot_dir"),
|
||||
@@ -244,49 +237,6 @@ class AutonomyConfig:
|
||||
budget_action=data.get("budget_action", "pause"),
|
||||
)
|
||||
|
||||
|
||||
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
|
||||
"""Map declarative ``verification:`` config entries onto hook instances.
|
||||
|
||||
Each entry is a dict with either a ``command`` key (→
|
||||
:class:`CommandVerificationHook`) or a ``file`` key (→
|
||||
:class:`FileCheckHook`). Unknown shapes are skipped.
|
||||
|
||||
Example::
|
||||
|
||||
verification:
|
||||
- {name: tests, command: "python -m pytest -q"}
|
||||
- {name: changelog, file: CHANGELOG.md, non_empty: true}
|
||||
"""
|
||||
from ..hooks.verification import CommandVerificationHook, FileCheckHook
|
||||
|
||||
hooks: List[Any] = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
blocking = entry.get("blocking", True)
|
||||
if "command" in entry:
|
||||
hooks.append(CommandVerificationHook(
|
||||
name=entry.get("name", "command"),
|
||||
command=entry["command"],
|
||||
cwd=entry.get("cwd"),
|
||||
timeout=entry.get("timeout", 60.0),
|
||||
blocking=blocking,
|
||||
))
|
||||
elif "file" in entry:
|
||||
hooks.append(FileCheckHook(
|
||||
name=entry.get("name", "file"),
|
||||
path=entry["file"],
|
||||
exists=entry.get("exists", True),
|
||||
non_empty=entry.get("non_empty", False),
|
||||
contains=entry.get("contains"),
|
||||
json_field=entry.get("json_field"),
|
||||
equals=entry.get("equals"),
|
||||
blocking=blocking,
|
||||
))
|
||||
return hooks
|
||||
|
||||
|
||||
class AutonomySignal(str, Enum):
|
||||
"""Signals detected from prompts for autonomy decisions.
|
||||
|
||||
|
||||
@@ -1211,15 +1211,7 @@ Your Goal: {self.goal}"""
|
||||
return content
|
||||
|
||||
def _extract_llm_response_content(self, response) -> Optional[str]:
|
||||
"""Return assistant message text, or a tool-call summary.
|
||||
|
||||
A well-formed response that simply carries no content -- a content filter,
|
||||
a refusal, or ``finish_reason="length"`` with zero output tokens -- yields
|
||||
the empty string. ``str(response)`` is reserved for responses this method
|
||||
could not parse at all, where the repr is a diagnostic rather than an
|
||||
answer; returning it for a merely empty message put the raw SDK object's
|
||||
repr in front of the user and into ``chat_history``.
|
||||
"""
|
||||
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
|
||||
if not response:
|
||||
return None
|
||||
try:
|
||||
@@ -1234,24 +1226,6 @@ Your Goal: {self.goal}"""
|
||||
if tool_calls:
|
||||
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
|
||||
return f"[tool_calls: {', '.join(names)}]"
|
||||
# Parsed cleanly; the model returned no content. A refusal
|
||||
# (safety) is carried in ``message.refusal`` independently of
|
||||
# ``finish_reason`` -- surface it so an empty answer isn't silent.
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal or finish_reason not in (None, "stop"):
|
||||
logging.warning(
|
||||
f"Agent {self.name}: model returned no content "
|
||||
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
|
||||
)
|
||||
# Record a distinct terminal reason so the empty answer is
|
||||
# actionable end-to-end (RunOutcome / CLI exit / --output
|
||||
# json) instead of a silent "completed" with empty text.
|
||||
from .run_outcome import classify_finish_reason
|
||||
stop_reason = classify_finish_reason(finish_reason, refusal)
|
||||
if stop_reason is not None:
|
||||
self._last_stop_reason = stop_reason
|
||||
return ""
|
||||
except (AttributeError, IndexError, TypeError) as e:
|
||||
logging.warning(
|
||||
f"Failed to extract LLM response content (falling back to str): {e}"
|
||||
@@ -1751,15 +1725,9 @@ Your Goal: {self.goal}"""
|
||||
return max_retries
|
||||
return 2
|
||||
|
||||
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0, cancel_token=None):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
|
||||
start_time = time.time()
|
||||
|
||||
# Reset the agent-level finish-reason classification at the start of each
|
||||
# OpenAI-native turn so a provider block/refusal recorded on a previous
|
||||
# run (see ``_extract_llm_response_content``) never leaks into this one.
|
||||
# The LiteLLM path resets its own backend flag independently.
|
||||
self._last_stop_reason = "completed"
|
||||
|
||||
# --- Proactive Context Budget Management (default-on) ---
|
||||
# Analyzes token budget BEFORE LLM call and applies appropriate strategy
|
||||
try:
|
||||
@@ -1862,7 +1830,6 @@ Your Goal: {self.goal}"""
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
streaming_response = self._chat_completion_with_retry(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -1882,11 +1849,6 @@ Your Goal: {self.goal}"""
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except ToolExecutionError:
|
||||
# A tool failed during the streaming attempt. Re-raise so it is
|
||||
# not relabelled as an LLM error and so the tool is not executed
|
||||
# a second time by the non-streaming fallback below.
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
# Don't retry if it's an LLMError that has exhausted retries
|
||||
@@ -1906,7 +1868,6 @@ Your Goal: {self.goal}"""
|
||||
final_response = streaming_response
|
||||
else:
|
||||
final_response = self._chat_completion_with_retry(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -1992,8 +1953,6 @@ Your Goal: {self.goal}"""
|
||||
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
|
||||
@@ -2008,6 +1967,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Use structured error classification for all error types (replaces legacy heuristic checks)
|
||||
from ..llm.error_classifier import classify_llm_error
|
||||
from ..llm.retry_utils import jittered_backoff
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "unknown"
|
||||
session_id = getattr(self, '_session_id', 'unknown')
|
||||
@@ -2057,8 +2017,7 @@ Your Goal: {self.goal}"""
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index,
|
||||
cancel_token=cancel_token
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -2104,8 +2063,7 @@ Your Goal: {self.goal}"""
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
_fallback_index=_fallback_index + 1
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
@@ -2122,8 +2080,7 @@ Your Goal: {self.goal}"""
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index,
|
||||
cancel_token=cancel_token
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -2162,7 +2119,7 @@ Your Goal: {self.goal}"""
|
||||
self,
|
||||
exc: Exception,
|
||||
messages,
|
||||
temperature=None,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
reasoning_steps=False,
|
||||
@@ -2352,7 +2309,7 @@ Your Goal: {self.goal}"""
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=None,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=None,
|
||||
reasoning_steps=False,
|
||||
@@ -2362,7 +2319,6 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
cancel_token=None,
|
||||
):
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
@@ -2395,7 +2351,6 @@ Your Goal: {self.goal}"""
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -2441,7 +2396,6 @@ Your Goal: {self.goal}"""
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -2489,7 +2443,7 @@ Your Goal: {self.goal}"""
|
||||
async def _execute_unified_achat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=None,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
|
||||
reasoning_steps=False,
|
||||
@@ -2827,11 +2781,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
# Count the whole request (history + system prompt + tool schemas),
|
||||
# otherwise a large prompt/tool block can push the call over the
|
||||
# window while the history alone stays under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
current_tokens = estimate_messages_tokens(optimized) + overhead
|
||||
current_tokens = estimate_messages_tokens(optimized)
|
||||
|
||||
# If over 95% of limit, apply emergency truncation
|
||||
if current_tokens > model_limit * 0.95:
|
||||
@@ -2839,67 +2789,20 @@ Your Goal: {self.goal}"""
|
||||
f"[{self.name}] Context at {current_tokens} tokens (limit: {model_limit}), "
|
||||
f"applying emergency truncation"
|
||||
)
|
||||
# Leave room for the fixed overhead so the full request lands
|
||||
# near 80% of the window rather than the history alone.
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
target = int(model_limit * 0.8) # Target 80% of limit
|
||||
optimized = self.context_manager.emergency_truncate(optimized, target)
|
||||
result["emergency_truncated"] = True
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized) + overhead
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized)
|
||||
except Exception as e:
|
||||
logging.debug(f"Hard limit check skipped: {e}")
|
||||
|
||||
return optimized, result
|
||||
|
||||
except Exception as e:
|
||||
# Context management should never break the chat flow, but silently
|
||||
# returning the unchanged history disables the hard-limit truncation
|
||||
# too and sends an over-budget request. Fall back to emergency
|
||||
# truncation so we still fit within the model window.
|
||||
logging.error(f"Context management failed: {e}")
|
||||
try:
|
||||
from ..context.budgeter import get_model_limit
|
||||
from ..context.tokens import estimate_messages_tokens
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
# Include the system prompt and tool schemas in the budget: the
|
||||
# request can exceed the window on those alone even when the
|
||||
# history is under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
if estimate_messages_tokens(messages) + overhead > model_limit * 0.95:
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
truncated = self.context_manager.emergency_truncate(messages, target)
|
||||
return truncated, {"emergency_truncated": True}
|
||||
except Exception as inner:
|
||||
logging.debug(f"Emergency truncation fallback skipped: {inner}")
|
||||
# Context management should never break the chat flow
|
||||
logging.warning(f"Context management error (continuing without): {e}")
|
||||
return messages, None
|
||||
|
||||
def _estimate_request_overhead_tokens(
|
||||
self,
|
||||
system_prompt: str = "",
|
||||
tools: Optional[list] = None,
|
||||
) -> int:
|
||||
"""Estimate non-history request tokens (system prompt + tool schemas).
|
||||
|
||||
Emergency-truncation thresholds must budget the full request, not just
|
||||
the message history, or a large system prompt / tool block can push the
|
||||
call over the model window undetected.
|
||||
"""
|
||||
try:
|
||||
from ..context.tokens import (
|
||||
estimate_tokens_heuristic,
|
||||
estimate_tool_schema_tokens,
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
overhead = 0
|
||||
if system_prompt:
|
||||
overhead += estimate_tokens_heuristic(system_prompt)
|
||||
if tools:
|
||||
overhead += estimate_tool_schema_tokens(tools)
|
||||
return overhead
|
||||
|
||||
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
|
||||
"""
|
||||
Truncate tool output according to configured budget.
|
||||
@@ -2959,14 +2862,7 @@ Your Goal: {self.goal}"""
|
||||
from ..approval import get_approval_registry
|
||||
|
||||
registry = get_approval_registry()
|
||||
# Key the grant by the unique per-instance scope id (not the
|
||||
# display name, which defaults to "Agent" for every unnamed
|
||||
# agent and would leak this grant onto other agents in the
|
||||
# same process). Falls back to name/display_name for objects
|
||||
# without a scope id.
|
||||
agent_name = getattr(self, "_approval_scope_id", None) or getattr(
|
||||
self, "display_name", getattr(self, "name", None)
|
||||
)
|
||||
agent_name = getattr(self, "display_name", getattr(self, "name", None))
|
||||
if agent_name: # Only approve if we have a stable agent identifier
|
||||
for _tn in tool_names:
|
||||
try:
|
||||
@@ -2992,7 +2888,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: Optional[float] = None, 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]:
|
||||
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.
|
||||
|
||||
@@ -3005,14 +2901,6 @@ Your Goal: {self.goal}"""
|
||||
'required' forces the LLM to call a tool before responding.
|
||||
...other args...
|
||||
"""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
# Slash-command invocation: /skill-name [args] renders the skill
|
||||
# body before any backend/LLM call.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
@@ -3039,19 +2927,6 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Check if external managed backend is configured
|
||||
if hasattr(self, 'backend') and self.backend is not None:
|
||||
# Honour configured throttling and cooperative cancellation before
|
||||
# handing off to the managed backend, so backend requests obey the
|
||||
# same request limits and cancel token as direct-LLM calls instead
|
||||
# of bypassing both guards (parity with the async achat() path).
|
||||
_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 backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
# Extract kwargs for delegation, excluding 'self' and function locals
|
||||
delegation_kwargs = {
|
||||
'temperature': temperature,
|
||||
@@ -3423,7 +3298,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback and display after validation
|
||||
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -3523,12 +3398,7 @@ Your Goal: {self.goal}"""
|
||||
agent_tools=agent_tools
|
||||
)
|
||||
|
||||
# G2 - thread the cancel token into the OpenAI-native tool
|
||||
# loop too. OpenAIClient.{a,}chat_completion_with_tools already
|
||||
# implements the between-iteration checks; without this hop they
|
||||
# are never armed, so /stop reports success while the remaining
|
||||
# tool calls keep executing.
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format, cancel_token=cancel_token)
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
@@ -3547,7 +3417,7 @@ Your Goal: {self.goal}"""
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3568,7 +3438,7 @@ Your Goal: {self.goal}"""
|
||||
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
@@ -3579,7 +3449,7 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3603,7 +3473,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self._using_custom_llm or self._openai_client is None:
|
||||
# For custom LLMs, we need to handle reflection differently
|
||||
# Use non-streaming to get complete JSON response
|
||||
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
|
||||
if not reflection_response or not reflection_response.choices:
|
||||
raise Exception("No response from reflection request")
|
||||
@@ -3620,14 +3490,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
|
||||
else:
|
||||
# Use OpenAI's structured output for OpenAI models
|
||||
_reflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_reflection_kwargs['temperature'] = temperature
|
||||
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=messages,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_reflection_kwargs
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -3645,7 +3512,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after satisfactory reflection
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
@@ -3665,7 +3532,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after max reflections
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3681,7 +3548,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# For reflection, always use non-streaming to ensure compatibility with sync adapters
|
||||
# and to avoid streaming complexity during regeneration process
|
||||
use_stream = False
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
content = response.choices[0].message.content
|
||||
response_text = content.strip() if content else ""
|
||||
reflection_count += 1
|
||||
@@ -3692,22 +3559,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
messages.append({"role": "assistant", "content": "Self Reflection failed."})
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
# Bound the retry, as the async path does. Without this
|
||||
# a persistently unparseable reflection (e.g. a
|
||||
# structured-output refusal, where message.parsed is
|
||||
# None) loops forever, two LLM calls at a time.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as guard_e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
continue # Continue even after error to try again
|
||||
except Exception:
|
||||
# Catch any exception from the inner try block and re-raise to outer handler
|
||||
@@ -3727,7 +3578,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return clean_triple_backticks(output)
|
||||
|
||||
async def achat(self, prompt: str, temperature: Optional[float] = None, 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 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:
|
||||
@@ -3735,14 +3586,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
attachments: Optional list of image/file paths that are ephemeral
|
||||
(used for THIS turn only, NEVER stored in history).
|
||||
"""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
# Slash-command invocation: /skill-name [args] renders the skill body.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
|
||||
@@ -3774,12 +3617,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# handing off to the managed backend, so backend requests obey the
|
||||
# same request limits and cancel token as direct-LLM calls instead
|
||||
# of bypassing both guards.
|
||||
_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 backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
_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 backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
@@ -3946,12 +3786,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
if not skip_retrieval and self.knowledge:
|
||||
# Knowledge.search is synchronous (Mem0 embedding + vector-store
|
||||
# I/O). Offload to a thread so it never blocks the event loop for
|
||||
# other coroutines sharing the loop.
|
||||
search_results = await asyncio.to_thread(
|
||||
self.knowledge.search, prompt, agent_id=self.agent_id
|
||||
)
|
||||
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
|
||||
if search_results:
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
|
||||
@@ -4296,14 +4131,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
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,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4417,10 +4249,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_kwargs = dict(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
)
|
||||
if temperature is not None:
|
||||
tool_call_kwargs['temperature'] = temperature
|
||||
if effective_tool_choice:
|
||||
tool_call_kwargs['tool_choice'] = effective_tool_choice
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
@@ -4434,14 +4265,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
_json_kwargs = {}
|
||||
if temperature is not None:
|
||||
_json_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
**_json_kwargs
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
@@ -4451,13 +4279,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
_plain_kwargs = {}
|
||||
if temperature is not None:
|
||||
_plain_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
**_plain_kwargs
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
@@ -4494,14 +4319,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
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,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4527,13 +4349,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
|
||||
]
|
||||
|
||||
_regen_kwargs = {}
|
||||
if temperature is not None:
|
||||
_regen_kwargs['temperature'] = temperature
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
**_regen_kwargs
|
||||
temperature=temperature
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
@@ -4620,12 +4439,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# after-context aggregation) are fired once, guarded, inside
|
||||
# execute_tool_async — no inline duplicate dispatch here.
|
||||
# Pass the tools list to honor task-scoped tools
|
||||
result = await self.execute_tool_async(
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id=getattr(tool_call, "id", None),
|
||||
tools_override=tools,
|
||||
)
|
||||
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
|
||||
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
@@ -4726,27 +4540,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Own the durable context for the complete streaming generator."""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
# Input-side guardrail runs BEFORE begin_durable_run so a rejected prompt
|
||||
# never opens (and therefore never finalizes) a durable run -- exact
|
||||
# parity with chat()/achat(), which validate input before begin_durable_run
|
||||
# and return early. Doing it here (not in _start_stream_impl) keeps a
|
||||
# blocked stream from being recorded as a "succeeded" durable run.
|
||||
if hasattr(self, '_validate_input_with_guardrail'):
|
||||
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
|
||||
if not _in_ok:
|
||||
logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}")
|
||||
yield f"[Input blocked by guardrail: {_in_err}]"
|
||||
return
|
||||
prompt = _in_prompt
|
||||
|
||||
durable_context = None
|
||||
durable_token = None
|
||||
try:
|
||||
@@ -4788,10 +4581,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"applied to streamed responses (iter_stream / stream=True). "
|
||||
"Use chat() for guardrail-validated output."
|
||||
)
|
||||
# Input-side guardrail validation is performed by _start_stream (before
|
||||
# begin_durable_run), so the prompt reaching this generator is already
|
||||
# validated/transformed. Kept out of this impl so a blocked prompt never
|
||||
# opens a durable run that would then finalize as "succeeded".
|
||||
try:
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
@@ -5045,10 +4834,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Handle any tool calls that were accumulated
|
||||
if tool_calls_data:
|
||||
# Mark where the tool turns begin so the follow-up can
|
||||
# append exactly the assistant tool-call and tool-result
|
||||
# messages onto the original request context below.
|
||||
tool_turn_start = len(self.chat_history)
|
||||
# Add assistant message with tool calls to chat history
|
||||
assistant_message = {"role": "assistant", "content": response_text}
|
||||
if tool_calls_data:
|
||||
@@ -5142,86 +4927,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Flush deferred media follow-ups after all tool replies.
|
||||
for _m in _deferred_media_followups:
|
||||
self._append_to_chat_history(_m)
|
||||
|
||||
# Ask the model for the answer now that the tools have
|
||||
# run. Without this the generator ends here: the tool
|
||||
# results sit in chat history, nothing is yielded, and
|
||||
# the caller receives an empty stream with no error --
|
||||
# indistinguishable from a model that had nothing to
|
||||
# say. The non-streaming path already does this round
|
||||
# trip, so the two disagreed about whether an answer
|
||||
# existed at all.
|
||||
#
|
||||
# Reuse the original `messages` (system prompt, output
|
||||
# constraints, memory context and the user turn) rather
|
||||
# than `self.chat_history`, which omits the system turn
|
||||
# and would let the follow-up ignore the agent role and
|
||||
# output format. Append only the tool turns produced by
|
||||
# this round -- everything added to chat history since
|
||||
# the completion began.
|
||||
followup_args = dict(completion_args)
|
||||
followup_args['messages'] = (
|
||||
list(messages) + self.chat_history[tool_turn_start:]
|
||||
)
|
||||
followup_args['stream'] = True
|
||||
# No tools on the follow-up: the model has its results
|
||||
# and should now answer, not call another tool. That
|
||||
# also bounds the turn to a single round of tools.
|
||||
followup_args.pop('tools', None)
|
||||
followup_args.pop('tool_choice', None)
|
||||
followup_args.pop('parallel_tool_calls', None)
|
||||
|
||||
# Bracket the follow-up with stream events so consumers
|
||||
# see request/terminal telemetry for the answer too --
|
||||
# the STREAM_END above referred only to the tool-call
|
||||
# round, before any answer existed.
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=time_module.perf_counter(),
|
||||
metadata={
|
||||
"model": self.llm,
|
||||
"message_count": len(followup_args['messages']),
|
||||
"phase": "post_tool_answer",
|
||||
}
|
||||
))
|
||||
|
||||
followup_text = ""
|
||||
followup_last_content_time = None
|
||||
for followup_chunk in self._openai_client.sync_client.chat.completions.create(
|
||||
**followup_args
|
||||
):
|
||||
if not followup_chunk.choices:
|
||||
continue
|
||||
piece = followup_chunk.choices[0].delta.content
|
||||
if piece:
|
||||
followup_text += piece
|
||||
followup_last_content_time = time_module.perf_counter()
|
||||
yield piece
|
||||
if followup_last_content_time:
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=followup_last_content_time
|
||||
))
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time_module.perf_counter(),
|
||||
metadata={"response_length": len(followup_text)}
|
||||
))
|
||||
if followup_text:
|
||||
self._append_to_chat_history(
|
||||
{"role": "assistant", "content": followup_text}
|
||||
)
|
||||
else:
|
||||
# Tools ran and the model then said nothing. Simply
|
||||
# returning here is indistinguishable from a model
|
||||
# that had nothing to say, and that is exactly how
|
||||
# this reached users: the generator ended having
|
||||
# yielded not one character, and the caller could
|
||||
# only report "no output" for a turn whose tools
|
||||
# had all succeeded. Fail loudly instead.
|
||||
raise RuntimeError(
|
||||
"the model called {} tool(s) and then returned "
|
||||
"no answer".format(len(tool_calls_data)))
|
||||
else:
|
||||
# Add complete response to chat history (text-only response)
|
||||
if response_text:
|
||||
@@ -5522,111 +5227,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
agent_id=getattr(self, 'name', None),
|
||||
)
|
||||
|
||||
def _get_model_middleware_manager(self):
|
||||
"""Return a MiddlewareManager if user model-call hooks are registered.
|
||||
|
||||
Companion to ``_get_tool_middleware_manager`` for the model-call side of
|
||||
the ``hooks/middleware.py`` surface (``before_model``/``after_model``/
|
||||
``wrap_model_call``). Reuses the same lazily-built manager and returns
|
||||
``None`` when no model hooks are present, preserving the zero-overhead
|
||||
fast path.
|
||||
"""
|
||||
hooks = getattr(self, '_hooks', None)
|
||||
if not hooks:
|
||||
return None
|
||||
manager = getattr(self, '_middleware_manager', None)
|
||||
if manager is None:
|
||||
from ..hooks import MiddlewareManager
|
||||
manager = MiddlewareManager(hooks)
|
||||
self._middleware_manager = manager
|
||||
return manager if manager.has_model_hooks else None
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_model_response(response):
|
||||
"""Unwrap a middleware ModelResponse back to the agent's return shape.
|
||||
|
||||
Preserves ``after_model``/``wrap_model_call`` mutations: if a hook
|
||||
rewrote ``response.content``, that string wins. The original SDK object
|
||||
stashed in ``extra["raw"]`` is only returned when the hook left content
|
||||
untouched (so non-string provider payloads pass through unchanged on the
|
||||
no-transform fast path).
|
||||
"""
|
||||
from ..hooks import ModelResponse
|
||||
if not isinstance(response, ModelResponse):
|
||||
return response
|
||||
raw = response.extra.get("raw")
|
||||
if isinstance(raw, str):
|
||||
# Content was derived from a string raw; hook edits live on content.
|
||||
return response.content
|
||||
# Non-string raw: return raw only if content still matches the value we
|
||||
# synthesized from it (i.e. hook didn't rewrite content).
|
||||
synthesized = raw if isinstance(raw, str) else (raw or "")
|
||||
if response.content == synthesized:
|
||||
return raw
|
||||
return response.content
|
||||
|
||||
def _chat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
|
||||
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""
|
||||
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
This method wraps the unified chat completion call and adds retry capability for
|
||||
transient failures like rate limits, network errors, and service outages.
|
||||
"""
|
||||
# Route through user-supplied model middleware (Agent(hooks=[...])) when
|
||||
# before_model/after_model/wrap_model_call hooks are registered. The
|
||||
# retry/backoff logic below becomes the middleware chain's final handler.
|
||||
manager = self._get_model_middleware_manager()
|
||||
if manager is not None:
|
||||
from ..hooks import ModelRequest, ModelResponse, InvocationContext
|
||||
|
||||
def _model_fn(req):
|
||||
# Honor before_model/wrap_model_call mutations by threading the
|
||||
# (possibly rewritten) request fields into the retry core rather
|
||||
# than the outer captured values.
|
||||
effective_temperature = req.temperature if req.temperature is not None else temperature
|
||||
effective_tools = req.tools if req.tools is not None else tools
|
||||
result = self._chat_completion_with_retry_core(
|
||||
req.messages, effective_temperature, effective_tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id,
|
||||
response_format, stream_callback=stream_callback,
|
||||
emit_events=emit_events, cancel_token=cancel_token,
|
||||
)
|
||||
return ModelResponse(
|
||||
content=result if isinstance(result, str) else (result or ""),
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
extra={"raw": result},
|
||||
)
|
||||
|
||||
request = ModelRequest(
|
||||
messages=messages,
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
temperature=temperature if temperature is not None else 1.0,
|
||||
tools=tools,
|
||||
context=InvocationContext(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
model_name=str(getattr(self, 'llm', '') or ''),
|
||||
),
|
||||
)
|
||||
response = manager.execute_model_call(request, _model_fn)
|
||||
return self._unwrap_model_response(response)
|
||||
|
||||
return self._chat_completion_with_retry_core(
|
||||
messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token,
|
||||
)
|
||||
|
||||
def _chat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
|
||||
"""Retry/backoff core for chat completion (middleware-agnostic)."""
|
||||
retry_config = getattr(self, '_retry_config', None)
|
||||
if not retry_config:
|
||||
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token)
|
||||
stream_callback=stream_callback, emit_events=emit_events)
|
||||
|
||||
from .retry_utils import jittered_backoff
|
||||
from ..hooks import HookEvent, OnRetryInput
|
||||
@@ -5639,8 +5251,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Call the underlying unified chat completion directly to avoid infinite recursion
|
||||
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token)
|
||||
stream_callback=stream_callback, emit_events=emit_events)
|
||||
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
@@ -5703,76 +5314,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# This should never be reached, but just in case
|
||||
raise RuntimeError("Retry loop completed without returning or raising an exception")
|
||||
|
||||
async def _achat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""
|
||||
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
This method wraps the async chat completion call and adds retry capability for
|
||||
transient failures like rate limits, network errors, and service outages.
|
||||
"""
|
||||
# Route through user-supplied model middleware (Agent(hooks=[...])) when
|
||||
# before_model/after_model/wrap_model_call hooks are registered, mirroring
|
||||
# the sync path so achat() honors the same hooks. The middleware chain is
|
||||
# synchronous; run it in an executor and bridge back to the async retry
|
||||
# core via the running loop so we never block or nest event loops.
|
||||
manager = self._get_model_middleware_manager()
|
||||
if manager is not None:
|
||||
import asyncio
|
||||
from ..hooks import ModelRequest, ModelResponse, InvocationContext
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _model_fn(req):
|
||||
effective_temperature = req.temperature if req.temperature is not None else temperature
|
||||
effective_tools = req.tools if req.tools is not None else tools
|
||||
fut = asyncio.run_coroutine_threadsafe(
|
||||
self._achat_completion_with_retry_core(
|
||||
req.messages, effective_temperature, effective_tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id,
|
||||
response_format, stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
result = fut.result()
|
||||
return ModelResponse(
|
||||
content=result if isinstance(result, str) else (result or ""),
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
extra={"raw": result},
|
||||
)
|
||||
|
||||
request = ModelRequest(
|
||||
messages=messages,
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
temperature=temperature if temperature is not None else 1.0,
|
||||
tools=tools,
|
||||
context=InvocationContext(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
model_name=str(getattr(self, 'llm', '') or ''),
|
||||
),
|
||||
)
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: manager.execute_model_call(request, _model_fn)
|
||||
)
|
||||
return self._unwrap_model_response(response)
|
||||
|
||||
return await self._achat_completion_with_retry_core(
|
||||
messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
)
|
||||
|
||||
async def _achat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""Async retry/backoff core for chat completion (middleware-agnostic)."""
|
||||
# Reset the agent-level finish-reason classification at the start of each
|
||||
# async OpenAI-native turn, mirroring the sync ``_chat_completion`` reset,
|
||||
# so a provider block/refusal recorded on a previous run (see
|
||||
# ``_extract_llm_response_content``) never leaks into this one. The LiteLLM
|
||||
# path resets its own backend flag independently.
|
||||
self._last_stop_reason = "completed"
|
||||
|
||||
retry_config = getattr(self, '_retry_config', None)
|
||||
if not retry_config:
|
||||
return await self._execute_unified_achat_completion(
|
||||
|
||||
@@ -59,8 +59,7 @@ class CodeAgent:
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "CodeAgent",
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None,
|
||||
llm: Optional[str] = None,
|
||||
code: Optional[Union[bool, Dict, CodeConfig]] = None,
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = True,
|
||||
@@ -70,16 +69,14 @@ class CodeAgent:
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Model to use (default: gpt-4o-mini)
|
||||
llm: LLM model (default: gpt-4o-mini)
|
||||
code: Code configuration (bool, dict, or CodeConfig)
|
||||
instructions: System instructions
|
||||
verbose: Enable verbose output
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.name = name
|
||||
self.llm = resolve_model_name(llm, model, "CodeAgent") or "gpt-4o-mini"
|
||||
self.llm = llm or "gpt-4o-mini"
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from ..runtime.journal import (
|
||||
KIND_MODEL_DECISION,
|
||||
KIND_TOOL_CALL,
|
||||
KIND_TOOL_RESULT,
|
||||
KIND_VERIFICATION,
|
||||
RunJournal,
|
||||
)
|
||||
|
||||
@@ -59,85 +58,6 @@ def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_restart_safe(value: Any) -> Optional[bool]:
|
||||
"""Accept only genuine booleans as a restart-safety declaration.
|
||||
|
||||
``None`` means undeclared. Any other type (e.g. the string ``"False"``) is a
|
||||
malformed declaration and is treated as unsafe so we fail closed rather than
|
||||
coercing a truthy value into an unintended replay.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return False
|
||||
|
||||
|
||||
def _canonical_tool_name(name: Any) -> str:
|
||||
"""Normalise a tool name for tolerant comparison across repair variants.
|
||||
|
||||
``execute_tool`` may repair case/separator variants (e.g. ``Send-Email`` →
|
||||
``send_email``); the declaration lookup mirrors that so an explicit
|
||||
``restart_safe`` declaration is not missed for a repaired name.
|
||||
"""
|
||||
return "".join(ch for ch in str(name).lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _declared_restart_safe(
|
||||
function_name: str, executor: Optional[Callable]
|
||||
) -> Optional[bool]:
|
||||
"""Return an explicit ``restart_safe`` declaration for ``function_name``.
|
||||
|
||||
Resolves the tool object first from the executor's owning agent (its live
|
||||
``tools``), then from the global tool registry, and reads its
|
||||
``restart_safe`` attribute. Returns ``None`` when the tool is undeclared or
|
||||
cannot be resolved.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
target = _canonical_tool_name(function_name)
|
||||
agent = getattr(executor, "__self__", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if isinstance(tools, (list, tuple)):
|
||||
for tool in tools:
|
||||
name = getattr(tool, "name", None) or getattr(tool, "__name__", None)
|
||||
if (
|
||||
name is not None
|
||||
and _canonical_tool_name(name) == target
|
||||
and getattr(tool, "restart_safe", None) is not None
|
||||
):
|
||||
return _normalize_restart_safe(tool.restart_safe)
|
||||
try:
|
||||
from ..tools import get_registry as _get_tool_registry
|
||||
|
||||
tool = _get_tool_registry().get(function_name)
|
||||
except Exception: # pragma: no cover - registry lookup is best-effort
|
||||
tool = None
|
||||
declared = getattr(tool, "restart_safe", None) if tool is not None else None
|
||||
return _normalize_restart_safe(declared)
|
||||
|
||||
|
||||
def _is_restart_safe(function_name: str, executor: Optional[Callable] = None) -> bool:
|
||||
"""Resolve whether a tool is safe to re-run for an in-flight durable resume.
|
||||
|
||||
An explicit ``restart_safe`` declaration on the tool contract wins. When the
|
||||
tool is undeclared (or cannot be resolved), fall back to the read-only name
|
||||
heuristic and, ultimately, fail closed — an effectful/unknown tool is *not*
|
||||
replayed so a missing declaration never causes a duplicated side effect.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
declared = _declared_restart_safe(function_name, executor)
|
||||
if declared is not None:
|
||||
return declared
|
||||
try:
|
||||
from .tool_execution import ToolExecutionMixin
|
||||
|
||||
return bool(ToolExecutionMixin._is_read_only_tool(function_name))
|
||||
except Exception: # pragma: no cover - heuristic import is best-effort
|
||||
return False
|
||||
|
||||
|
||||
class DurableRunContext:
|
||||
"""Per-turn journal state; safe for parallel tool-call wrappers."""
|
||||
|
||||
@@ -160,10 +80,6 @@ class DurableRunContext:
|
||||
self._results_by_tool_call_id: Dict[str, Any] = {}
|
||||
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
# Names of in-flight tool calls (journalled tool_call with no tool_result
|
||||
# at the crash). Used only when replaying to gate replay by restart-safety.
|
||||
self._inflight_names_by_call_id: Dict[str, str] = {}
|
||||
self._inflight_names_by_fingerprint: Dict[str, str] = {}
|
||||
self._recorded_iterations = set()
|
||||
for event in events:
|
||||
if event.kind == KIND_TOOL_RESULT:
|
||||
@@ -190,11 +106,8 @@ class DurableRunContext:
|
||||
or f"{self.run_id}:{event.seq}:{name}"
|
||||
)
|
||||
pending = (event.seq, call_id, args, key)
|
||||
fingerprint = self._fingerprint(name, args)
|
||||
self._pending_by_call_id[call_id] = pending
|
||||
self._pending_by_fingerprint[fingerprint] = pending
|
||||
self._inflight_names_by_call_id[call_id] = name
|
||||
self._inflight_names_by_fingerprint[fingerprint] = name
|
||||
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
|
||||
|
||||
self._resume_completed_steps = len(self._recorded_iterations)
|
||||
|
||||
@@ -206,44 +119,6 @@ class DurableRunContext:
|
||||
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
|
||||
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
|
||||
|
||||
def _is_inflight_call(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
tool_call_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Whether this call was in-flight (journalled, no result) at the crash.
|
||||
|
||||
Only meaningful while replaying: such calls have a recorded ``tool_call``
|
||||
but no ``tool_result``, so re-driving the loop would re-execute their
|
||||
side effect. Completed calls (already have a result) are replayed
|
||||
upstream and never reach this check.
|
||||
"""
|
||||
if not self.replaying:
|
||||
return False
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._inflight_names_by_call_id:
|
||||
return True
|
||||
return self._fingerprint(function_name, arguments) in (
|
||||
self._inflight_names_by_fingerprint
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _not_safely_resumable_result(function_name: str) -> Dict[str, Any]:
|
||||
"""Typed, operator-visible outcome for a gated in-flight effectful tool."""
|
||||
return {
|
||||
"error": (
|
||||
f"Tool '{function_name}' was in-flight when the durable run "
|
||||
"crashed and is not declared restart_safe; its outcome could not "
|
||||
"be confirmed. It was not re-executed on resume to avoid a "
|
||||
"duplicate side effect. Reconcile the external action manually "
|
||||
"or mark the tool @tool(restart_safe=True) if it is idempotent."
|
||||
),
|
||||
"error_type": "NotSafelyResumable",
|
||||
"not_safely_resumable": True,
|
||||
"restart_safe": False,
|
||||
}
|
||||
|
||||
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Append completed decision/result pairs once, before the next model call."""
|
||||
if not self.replaying or self._restored:
|
||||
@@ -402,26 +277,6 @@ class DurableRunContext:
|
||||
)
|
||||
self.finalize("failed")
|
||||
|
||||
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
|
||||
"""Append verification-gate results as durable ``verification`` events.
|
||||
|
||||
Each payload gets its own fresh ``seq`` so the events append cleanly
|
||||
alongside the tool-call cursor without colliding on ``(run_id, seq,
|
||||
kind)``. Best-effort: journalling never raises into the gate.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
with self._lock:
|
||||
for payload in payloads:
|
||||
seq = self._next_seq
|
||||
self._next_seq += 1
|
||||
try:
|
||||
self.journal.append(
|
||||
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
|
||||
)
|
||||
except Exception: # pragma: no cover - journalling is best-effort
|
||||
pass
|
||||
|
||||
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
|
||||
if execute_tool_fn is None:
|
||||
return None
|
||||
@@ -431,19 +286,9 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = self._allocate_step(
|
||||
function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return self._record_result(
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
@@ -474,20 +319,9 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = await asyncio.to_thread(
|
||||
self._allocate_step, function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return await asyncio.to_thread(
|
||||
self._record_result,
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
|
||||
@@ -110,8 +110,8 @@ class EmbeddingAgent:
|
||||
- EmbeddingConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "EmbeddingAgent")
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "EmbeddingAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -43,67 +43,24 @@ HERE = "this machine"
|
||||
# a place that never executes.
|
||||
_ALIASES = {"native": "sandlock", "local": "subprocess"}
|
||||
|
||||
# ...but only for the SANDBOX registry. "local" resolves to two different
|
||||
# backends depending on which parameter carried it:
|
||||
# run_in="local" -> SandboxManager collapses it to subprocess:
|
||||
# scrubbed environment, blocked commands and paths
|
||||
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
|
||||
# current directory, full environment inherited,
|
||||
# no security policy at all
|
||||
# Describing the second in the first's words overstates it, which is the exact
|
||||
# failure this module exists to prevent. One word, two backends -- so the
|
||||
# phrase has to depend on where the word came from.
|
||||
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
|
||||
|
||||
# Tiers that separate a process but do NOT contain it. Verified: under the
|
||||
# subprocess backend, outbound network still works and ~/.ssh is readable even
|
||||
# though SecurityPolicy declares them blocked.
|
||||
# `native` is deliberately absent: it aliases sandlock, which applies Landlock,
|
||||
# seccomp, a scrubbed environment and deny-all networking. Listing it here made
|
||||
# tools_run_on="native" and tools_run_on="sandlock" -- the same backend --
|
||||
# disagree about whether they contain anything.
|
||||
_NOT_A_BOUNDARY = {"subprocess", "local"}
|
||||
_NOT_A_BOUNDARY = {"subprocess", "local", "native"}
|
||||
|
||||
|
||||
def say_place(name: Any, *, via: str = "sandbox") -> str:
|
||||
"""Turn a place name into a phrase a non-developer can read.
|
||||
|
||||
``via`` says which registry resolved the name -- ``"compute"`` for
|
||||
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
|
||||
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
|
||||
"""
|
||||
def say_place(name: Any) -> str:
|
||||
"""Turn a provider name into a phrase a non-developer can read."""
|
||||
if name is None:
|
||||
return HERE
|
||||
if name is True:
|
||||
return _PLACE_WORDS["subprocess"]
|
||||
if not isinstance(name, str):
|
||||
# A configured provider instance: use its own declared name if it has
|
||||
# one. Normalise BEFORE the compute-only check, or a backend holding a
|
||||
# LocalCompute object (provider_name="local") skips the branch and gets
|
||||
# described in the subprocess backend's stronger words -- the exact
|
||||
# overstatement this module exists to prevent.
|
||||
# A configured provider instance: use its own declared name if it has one.
|
||||
name = getattr(name, "provider_name", None) or type(name).__name__
|
||||
if via == "compute" and name.lower() in _COMPUTE_ONLY_WORDS:
|
||||
return _COMPUTE_ONLY_WORDS[name.lower()]
|
||||
key = str(name).lower()
|
||||
key = _ALIASES.get(key, key)
|
||||
|
||||
known = _PLACE_WORDS.get(key)
|
||||
if known:
|
||||
return known
|
||||
|
||||
# A place contributed by another package can name itself; the bare name is
|
||||
# the last resort rather than the only option, because the phrase table is
|
||||
# a literal that cannot know about packages installed later.
|
||||
try:
|
||||
from ..managed._compute_bridge import contributed_display_name
|
||||
|
||||
declared = contributed_display_name(key)
|
||||
if declared:
|
||||
return declared
|
||||
except Exception:
|
||||
pass
|
||||
return str(name)
|
||||
return _PLACE_WORDS.get(key, str(name))
|
||||
|
||||
|
||||
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
@@ -111,21 +68,6 @@ def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
if backend is None:
|
||||
return None
|
||||
|
||||
# Ask the backend what it is, rather than guessing from its class name.
|
||||
# Name-matching missed any backend that did not happen to contain "Hosted"
|
||||
# or "Anthropic" -- a self-hosted docker backend reported that it was
|
||||
# thinking on this machine while the whole loop ran in a container.
|
||||
declared = getattr(backend, "provider_name", None)
|
||||
if isinstance(declared, str):
|
||||
try:
|
||||
from .placement import managed_runtimes
|
||||
|
||||
if declared.lower() in managed_runtimes():
|
||||
where = say_place(declared)
|
||||
return {"thinks_on": where, "tools_run_on": where}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls = type(backend).__name__
|
||||
# Hosted runtimes move the entire turn off this machine.
|
||||
if "Hosted" in cls or "Anthropic" in cls:
|
||||
@@ -133,13 +75,10 @@ def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
where = say_place(provider)
|
||||
return {"thinks_on": where, "tools_run_on": where}
|
||||
|
||||
# Local/sandboxed agents keep the loop here and may push tools out. A
|
||||
# backend's compute object is a COMPUTE-registry provider, so it is read
|
||||
# with the compute vocabulary: a LocalCompute here is the unrestricted
|
||||
# shell, not the subprocess backend "local" collapses to under run_in=.
|
||||
# Local/sandboxed agents keep the loop here and may push tools out.
|
||||
compute = getattr(backend, "_compute", None) or getattr(backend, "compute", None)
|
||||
if compute is not None:
|
||||
return {"thinks_on": HERE, "tools_run_on": say_place(compute, via="compute")}
|
||||
return {"thinks_on": HERE, "tools_run_on": say_place(compute)}
|
||||
return {"thinks_on": HERE, "tools_run_on": HERE}
|
||||
|
||||
|
||||
@@ -155,21 +94,11 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
|
||||
if from_backend:
|
||||
places.update(from_backend)
|
||||
|
||||
where_tools = getattr(obj, "tools_run_on", None)
|
||||
if where_tools is not None:
|
||||
where = say_place(where_tools, via="compute")
|
||||
run_on = getattr(obj, "run_on", None)
|
||||
if run_on is not None:
|
||||
where = say_place(run_on)
|
||||
places["tools_run_on"] = f"{where} (shared)" if shared else where
|
||||
|
||||
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
|
||||
# thinking as well. _backend_places() already reports that (run_on= is
|
||||
# turned into a backend at construction); this only covers an object
|
||||
# that carries the name without a built backend.
|
||||
whole = getattr(obj, "run_on", None)
|
||||
if whole is not None and not from_backend:
|
||||
everywhere = say_place(whole)
|
||||
places["thinks_on"] = everywhere
|
||||
places["tools_run_on"] = everywhere
|
||||
|
||||
sandbox_config = getattr(obj, "sandbox_config", None)
|
||||
if sandbox_config is not None:
|
||||
places["code_runs_on"] = say_place(
|
||||
@@ -180,42 +109,6 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
|
||||
return places
|
||||
|
||||
|
||||
def _tools_staying_here(obj: Any) -> list:
|
||||
"""Names of the object's own tools, which do NOT move to the sandbox.
|
||||
|
||||
Only four tool names are bridged (``execute_command``, ``read_file``,
|
||||
``write_file``, ``list_files``); everything else the user passed keeps
|
||||
running in this process, against this filesystem. The capability prompt
|
||||
tells the model the sandbox is where things happen, so anything staying
|
||||
behind has to be said out loud.
|
||||
|
||||
Reads the pre-attach tool list when a sandbox is already attached: by then
|
||||
``agent.tools`` has been rewritten and the originals live in the sandbox's
|
||||
restore record.
|
||||
"""
|
||||
try:
|
||||
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
|
||||
except Exception:
|
||||
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
|
||||
|
||||
tools = None
|
||||
shared = getattr(obj, "_tools_sandbox", None)
|
||||
if shared is not None:
|
||||
for patched, original_tools, _ in getattr(shared, "_patched", []):
|
||||
if patched is obj:
|
||||
tools = original_tools
|
||||
break
|
||||
if tools is None:
|
||||
tools = getattr(obj, "tools", None) or []
|
||||
|
||||
names = []
|
||||
for tool in tools:
|
||||
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
|
||||
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def repr_fields(obj: Any, *, shared: bool = False) -> str:
|
||||
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
|
||||
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
|
||||
@@ -233,20 +126,12 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
f"Tools run on {places['tools_run_on']}.",
|
||||
]
|
||||
|
||||
if shared and getattr(obj, "tools_run_on", None) is not None:
|
||||
if shared and getattr(obj, "run_on", None) is not None:
|
||||
lines.append(
|
||||
"Every step shares that same sandbox, so a file written by one step "
|
||||
"is visible to the next."
|
||||
)
|
||||
|
||||
staying = _tools_staying_here(obj)
|
||||
if staying and places["tools_run_on"] != places["thinks_on"]:
|
||||
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
|
||||
lines.append(
|
||||
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
|
||||
f"and code tools move. They read and write this machine's files."
|
||||
)
|
||||
|
||||
if "code_runs_on" in places:
|
||||
lines.append(
|
||||
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
|
||||
@@ -254,7 +139,6 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
|
||||
# Say plainly when the chosen tier is not a security boundary.
|
||||
raw = [
|
||||
getattr(obj, "tools_run_on", None),
|
||||
getattr(obj, "run_on", None),
|
||||
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
|
||||
]
|
||||
@@ -263,8 +147,7 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
):
|
||||
lines.append(
|
||||
"Note: a separate process is not a security boundary -- it can still "
|
||||
"reach the network and read your files, and its blocked-command "
|
||||
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
|
||||
"reach the network and read your files. Use docker or a cloud "
|
||||
"provider for untrusted code."
|
||||
)
|
||||
|
||||
|
||||
@@ -297,33 +297,7 @@ class ExecutionMixin:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - normalised into outcome
|
||||
return RunOutcome.from_exception(exc)
|
||||
return self._outcome_for_result(result)
|
||||
|
||||
def _outcome_for_result(self, result):
|
||||
"""Map a normally-returned run result into a canonical RunOutcome.
|
||||
|
||||
A run that returned without raising is ``completed`` *unless* the core
|
||||
recorded a provider block/refusal/truncation on ``last_stop_reason`` from
|
||||
the LLM ``finish_reason``/refusal signal — in which case the specific,
|
||||
actionable terminal reason (``content_filtered | refused |
|
||||
length_truncated``) is surfaced instead of a silent empty ``completed``.
|
||||
Any other stop reason (``completed``/``max_steps``/unknown) preserves the
|
||||
existing ``completed`` semantics, so behaviour is unchanged on success.
|
||||
"""
|
||||
from .run_outcome import RunOutcome, PROVIDER_BLOCK_REASONS
|
||||
output = str(result) if result is not None else None
|
||||
# Read the recorded classification without letting a lookup problem mask a
|
||||
# provider block as a successful ``completed``. Only an expected
|
||||
# missing-state lookup (``AttributeError``) is treated as "no reason
|
||||
# recorded"; anything else propagates rather than silently succeeding.
|
||||
reason = None
|
||||
try:
|
||||
reason = getattr(self, "last_stop_reason", None)
|
||||
except AttributeError:
|
||||
reason = None
|
||||
if reason in PROVIDER_BLOCK_REASONS:
|
||||
return RunOutcome(reason=reason, output=output)
|
||||
return RunOutcome.completed(output=output)
|
||||
return RunOutcome.completed(output=str(result) if result is not None else None)
|
||||
|
||||
def run(self, prompt: str, **kwargs: Any) -> Optional[str]:
|
||||
"""Execute agent silently and return structured result.
|
||||
@@ -409,7 +383,7 @@ class ExecutionMixin:
|
||||
result = executor()
|
||||
except BaseException as exc: # noqa: BLE001 - normalised into outcome
|
||||
return RunOutcome.from_exception(exc)
|
||||
return self._outcome_for_result(result)
|
||||
return RunOutcome.completed(output=str(result) if result is not None else None)
|
||||
|
||||
def _delegate_to_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Delegate execution to external managed backend (e.g., ManagedAgentIntegration).
|
||||
@@ -1545,7 +1519,6 @@ Write the complete compiled report:"""
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1591,43 +1564,18 @@ Write the complete compiled report:"""
|
||||
result.get("approval_error") or
|
||||
result.get("circuit_open") or
|
||||
result.get("loop_blocked") or
|
||||
result.get("_praison_retryable") is False):
|
||||
result.pop("_praison_retryable", None)
|
||||
result.get("retryable") is False):
|
||||
return result
|
||||
|
||||
# The body already ran and may have completed its side effect
|
||||
# before failing. Honour an explicit restart_safe=False /
|
||||
# idempotent=False declaration, exactly as the sync loops do
|
||||
# (tool_execution.py:884 and :2115). Without this the async
|
||||
# path accepts the declaration and silently re-runs the tool
|
||||
# up to max_attempts times -- charging a card three times for
|
||||
# a tool the author marked "must never be silently re-executed".
|
||||
if self._tool_declares_not_idempotent(function_name, tools_override):
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
# Check if we should retry. A tool that *raised* is flattened
|
||||
# into an error dict before it can be classified, so it always
|
||||
# arrives here as "unknown" — which no default retry_on
|
||||
# contains. The sync path escalates exactly those error types
|
||||
# to its own retry loop (tool_execution.py:853-866); honouring
|
||||
# the impl's explicit `retryable` flag is how the async path
|
||||
# reaches the same decision instead of never retrying at all.
|
||||
# `_praison_retryable` is a private control-plane key (see
|
||||
# _execute_tool_async_impl): a tool's own result may legitimately
|
||||
# contain "retryable" (common for HTTP-API wrappers) and must
|
||||
# never steer our retry loop.
|
||||
if (not retry_policy.should_retry(error_type, attempt)
|
||||
and result.get("_praison_retryable") is not True):
|
||||
result.pop("_praison_retryable", None)
|
||||
# Check if we should retry
|
||||
if not retry_policy.should_retry(error_type, attempt):
|
||||
return result
|
||||
|
||||
# Don't retry on last attempt
|
||||
if attempt == retry_policy.max_attempts - 1:
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
# Emit retry hook event
|
||||
@@ -1725,18 +1673,6 @@ Write the complete compiled report:"""
|
||||
logging.error(f"Function {function_name} not found in tools")
|
||||
return {"error": f"Function {function_name} not found in tools"}
|
||||
|
||||
# Circuit breaker (pre-execution) — parity with the sync path
|
||||
# (_execute_tool_with_circuit_breaker_impl). Without this the async
|
||||
# tool path never engages the per-agent breaker, so a persistently
|
||||
# failing external tool is hammered indefinitely via achat()/astart()
|
||||
# and the model never receives the ``circuit_open`` signal the async
|
||||
# retry loop already checks for. The helpers are pure/sync.
|
||||
breaker_precheck = getattr(self, '_circuit_breaker_precheck', None)
|
||||
if breaker_precheck is not None:
|
||||
open_result = breaker_precheck(function_name)
|
||||
if open_result is not None:
|
||||
return open_result
|
||||
|
||||
# Loop guard (pre-execution) — parity with the sync path in
|
||||
# tool_execution.py. The async path previously had no loop-guard or
|
||||
# doom-loop protection, so a repeatedly-failing tool could be hammered
|
||||
@@ -1816,87 +1752,6 @@ Write the complete compiled report:"""
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
)
|
||||
|
||||
# Circuit-breaker parity with the sync path (tool_execution.py).
|
||||
# The async tool path previously had no breaker, so a repeatedly
|
||||
# failing tool could be hammered every turn via achat()/async
|
||||
# workflows. Wrap the invocation through the same per-agent/per-tool
|
||||
# breaker (CircuitBreaker.acall is event-loop safe) so repeated
|
||||
# failures OPEN the circuit and short-circuit further calls. A
|
||||
# CircuitBreakerException is surfaced as an error dict carrying
|
||||
# circuit_open=True, which the retry loop in
|
||||
# _execute_tool_async_with_retry already treats as terminal.
|
||||
breaker = None
|
||||
# Sentinel exception type so the `except` clause below is always a
|
||||
# valid type even when the circuit_breaker module is unavailable
|
||||
# (a never-raised local class never matches a real exception).
|
||||
class _CircuitBreakerException(Exception):
|
||||
pass
|
||||
try:
|
||||
from ..tools.circuit_breaker import (
|
||||
get_circuit_breaker,
|
||||
CircuitBreakerConfig,
|
||||
CircuitBreakerException as _CircuitBreakerException,
|
||||
)
|
||||
breaker_name = f"tool_{id(self)}_{function_name}"
|
||||
breaker = get_circuit_breaker(
|
||||
breaker_name,
|
||||
CircuitBreakerConfig(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
timeout=30.0,
|
||||
graceful_degradation=True,
|
||||
),
|
||||
)
|
||||
if hasattr(self, "_register_breaker_finalizer"):
|
||||
self._register_breaker_finalizer(breaker_name)
|
||||
except ImportError:
|
||||
# Circuit breaker not available - fall back to direct invocation.
|
||||
logging.debug("Circuit breaker not available on async path, executing directly")
|
||||
|
||||
# Sentinel used to register an error-dict result as a breaker
|
||||
# failure without losing the dict, mirroring the sync path's
|
||||
# _ToolFailure wrapper (tool_execution.py). Approval/permission/
|
||||
# policy/guardrail denials are NOT treated as breaker failures.
|
||||
class _ToolFailure(Exception):
|
||||
def __init__(self, error_dict):
|
||||
self.error_dict = error_dict
|
||||
super().__init__(error_dict.get("error", "Tool execution failed"))
|
||||
|
||||
async def _invoke_for_breaker():
|
||||
r = await _invoke()
|
||||
if isinstance(r, dict) and r.get("error") and \
|
||||
not r.get("approval_denied") and \
|
||||
not r.get("permission_denied") and \
|
||||
not r.get("approval_error") and \
|
||||
not r.get("policy_denied") and \
|
||||
not r.get("guardrail_denied"):
|
||||
raise _ToolFailure(r)
|
||||
return r
|
||||
|
||||
async def _invoke_guarded():
|
||||
if breaker is None:
|
||||
return await _invoke()
|
||||
try:
|
||||
return await breaker.acall(_invoke_for_breaker)
|
||||
except _ToolFailure as tf:
|
||||
# Failure was counted by the breaker; return the original dict.
|
||||
return tf.error_dict
|
||||
|
||||
def _circuit_open_result():
|
||||
# Record the rejection as a failure so repeated open-circuit
|
||||
# rejections still feed the loop guard, then surface an error
|
||||
# dict carrying circuit_open=True — the retry loop in
|
||||
# _execute_tool_async_with_retry treats it as terminal.
|
||||
if loop_guard is not None:
|
||||
loop_guard.record(function_name, arguments, False, result=None)
|
||||
return {
|
||||
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
|
||||
"circuit_open": True,
|
||||
"agent_name": getattr(self, "name", None),
|
||||
"session_id": getattr(self, "_session_id", None),
|
||||
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
|
||||
}
|
||||
|
||||
# Apply the per-agent tool timeout (ToolConfig.timeout) so the async
|
||||
# path matches the sync path in tool_execution.py. asyncio.wait_for
|
||||
# cannot kill a stuck sync tool running in the executor (same caveat
|
||||
@@ -1905,10 +1760,7 @@ Write the complete compiled report:"""
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
try:
|
||||
result = await asyncio.wait_for(_invoke_guarded(), timeout=tool_timeout)
|
||||
except _CircuitBreakerException as cbe:
|
||||
logging.warning(f"Tool '{function_name}' circuit breaker open: {cbe}")
|
||||
return _circuit_open_result()
|
||||
result = await asyncio.wait_for(_invoke(), timeout=tool_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
# Mark as non-retryable: asyncio.wait_for cannot cancel a sync
|
||||
@@ -1922,33 +1774,10 @@ Write the complete compiled report:"""
|
||||
result = {
|
||||
"error": f"Tool timed out after {tool_timeout}s",
|
||||
"timeout": True,
|
||||
"_praison_retryable": False,
|
||||
"retryable": False,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
result = await _invoke_guarded()
|
||||
except _CircuitBreakerException as cbe:
|
||||
logging.warning(f"Tool '{function_name}' circuit breaker open: {cbe}")
|
||||
return _circuit_open_result()
|
||||
|
||||
# Circuit breaker (post-execution) — record the outcome so
|
||||
# repeated failures open the breaker, mirroring the sync path's
|
||||
# breaker.call. Approval/permission/policy/guardrail denials are
|
||||
# NOT counted as tool failures (same exclusions the sync wrapper
|
||||
# applies in _execute_tool_with_circuit_breaker_impl), so a gated
|
||||
# tool never trips the breaker.
|
||||
breaker_record = getattr(self, '_circuit_breaker_record', None)
|
||||
if breaker_record is not None:
|
||||
is_breaker_failure = (
|
||||
isinstance(result, dict)
|
||||
and result.get("error")
|
||||
and not result.get("approval_denied")
|
||||
and not result.get("permission_denied")
|
||||
and not result.get("approval_error")
|
||||
and not result.get("policy_denied")
|
||||
and not result.get("guardrail_denied")
|
||||
)
|
||||
breaker_record(function_name, not is_breaker_failure)
|
||||
result = await _invoke()
|
||||
|
||||
# Loop guard (post-execution) — record the outcome and surface a
|
||||
# block/halt decision back to the model on this same turn, mirroring
|
||||
@@ -1979,43 +1808,13 @@ Write the complete compiled report:"""
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing {function_name}: {str(e)}", exc_info=True)
|
||||
# Circuit breaker (failure on raised exception) — a raised tool
|
||||
# exception is a failure just like an error-dict result, and the
|
||||
# sync path's ``breaker.call`` counts it. Record it here so the
|
||||
# breaker still opens after repeated raises; otherwise the
|
||||
# post-invoke record block above is skipped and raised failures
|
||||
# never trip the breaker. Approval/permission/policy/guardrail
|
||||
# denials surface as error dicts (handled above), not raises, so
|
||||
# this path only ever sees genuine tool failures.
|
||||
breaker_record = getattr(self, '_circuit_breaker_record', None)
|
||||
if breaker_record is not None:
|
||||
breaker_record(function_name, False)
|
||||
# Record the failed invocation so repeated identical failures
|
||||
# accumulate toward the loop-guard BLOCK/HALT thresholds — a raised
|
||||
# tool exception is a failure just like an error-dict result on the
|
||||
# sync path. The next pre-execution check will then BLOCK/HALT.
|
||||
if loop_guard is not None:
|
||||
loop_guard.record(function_name, arguments, False, result=None)
|
||||
# Carry the retry verdict with the flattened exception. Without it
|
||||
# the caller only sees an opaque error string, classifies it as
|
||||
# "unknown" (never in RetryPolicy.retry_on) and gives up — while
|
||||
# the sync path, which still has the exception object, retries.
|
||||
# Mirror the sync path exactly: a ToolExecutionError already
|
||||
# carries an explicit is_retryable verdict (e.g. a terminal
|
||||
# non-retryable failure) and must be honoured verbatim; for any
|
||||
# other exception apply the same terminal rule — programming
|
||||
# errors are terminal, everything else may be retried.
|
||||
if isinstance(e, ToolExecutionError):
|
||||
retryable = e.is_retryable
|
||||
else:
|
||||
retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
|
||||
# Private control-plane key: a tool's own result may legitimately
|
||||
# contain "retryable" (common for HTTP-API wrappers) and must not
|
||||
# collide with the framework's verdict nor leak to the model.
|
||||
return {
|
||||
"error": f"Error executing {function_name}: {str(e)}",
|
||||
"_praison_retryable": retryable,
|
||||
}
|
||||
return {"error": f"Error executing {function_name}: {str(e)}"}
|
||||
|
||||
except ToolExecutionError:
|
||||
# A loop-guard HALT must propagate to stop the run, mirroring the sync
|
||||
@@ -2025,26 +1824,18 @@ Write the complete compiled report:"""
|
||||
logging.error(f"Error in execute_tool_async: {str(e)}", exc_info=True)
|
||||
return {"error": f"Error in execute_tool_async: {str(e)}"}
|
||||
|
||||
def launch(self, path: str = '/', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
|
||||
def launch(self, path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
|
||||
"""
|
||||
Launch the agent as an HTTP API endpoint or MCP server.
|
||||
|
||||
This method now delegates to protocol-based launchers to follow
|
||||
the protocol-driven architecture. Heavy implementations (FastAPI/uvicorn)
|
||||
are only imported when needed through lazy loading.
|
||||
|
||||
Request authorisation matches ``PraisonAIAgents.launch``: when
|
||||
``PRAISONAI_LAUNCH_AUTH_TOKEN`` is set, every HTTP route requires that
|
||||
bearer token (401 otherwise). When it is unset and ``host`` is
|
||||
non-loopback, a one-time token is generated and printed rather than
|
||||
serving an unauthenticated endpoint on all interfaces.
|
||||
|
||||
|
||||
Args:
|
||||
path: API endpoint path (default: '/') for HTTP, or base path for MCP.
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
|
||||
avoid binding all interfaces by default — pass '0.0.0.0'
|
||||
explicitly to expose externally).
|
||||
host: Server host (default: '0.0.0.0')
|
||||
debug: Enable debug mode (default: False)
|
||||
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
|
||||
|
||||
@@ -2068,8 +1859,6 @@ Write the complete compiled report:"""
|
||||
"""
|
||||
global _server_started, _registered_agents, _shared_apps, _server_lock
|
||||
|
||||
from .launch_security import authorise_launch_request, resolve_launch_host
|
||||
|
||||
# Try to import FastAPI dependencies - lazy loading
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -2094,15 +1883,7 @@ Write the complete compiled report:"""
|
||||
print("\nOr install all API dependencies with:")
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
# Fail-closed bind guard: refuse to serve keyless on a non-loopback host
|
||||
# (auto-generates and prints a one-time token instead). Matches the
|
||||
# serve/jobs precedent and keeps this path in lock-step with
|
||||
# PraisonAIAgents.launch. Applied only after the dependency guard above
|
||||
# so a launch that bails out (missing deps) cannot mutate the
|
||||
# process-wide launch token and retroactively 401 a running endpoint.
|
||||
host = resolve_launch_host(host)
|
||||
|
||||
|
||||
should_start = False
|
||||
with _server_lock:
|
||||
# Initialize port-specific collections if needed (once per port)
|
||||
@@ -2155,8 +1936,6 @@ Write the complete compiled report:"""
|
||||
# Define the endpoint handler
|
||||
@_shared_apps[port].post(path)
|
||||
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import contextvars
|
||||
@@ -470,46 +469,12 @@ class Handoff:
|
||||
return value
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
@contextmanager
|
||||
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
|
||||
"""Temporarily seed the filtered handoff context onto the target agent.
|
||||
|
||||
The configured ``context_policy``/``input_filter`` produce the messages the
|
||||
target should see; without seeding them the target only ever received the
|
||||
literal handoff prompt string and the configured policy had no effect.
|
||||
|
||||
The seeding is *invocation-scoped*: the prior messages are prepended for the
|
||||
duration of this handoff and the target's original ``chat_history`` is
|
||||
restored on exit. This keeps handoff context from leaking into later
|
||||
handoffs or ordinary chats that reuse the same target agent, and stops the
|
||||
target prompt from growing across sequential handoffs.
|
||||
No shared mutable state is retained between agents.
|
||||
"""
|
||||
existing = getattr(self.agent, 'chat_history', None)
|
||||
if not prior_messages or not isinstance(existing, list):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
prior = list(prior_messages)
|
||||
except TypeError:
|
||||
yield
|
||||
return
|
||||
original = existing
|
||||
try:
|
||||
# Avoid re-seeding if the context is already at the head.
|
||||
if existing[:len(prior)] != prior:
|
||||
self.agent.chat_history = prior + existing
|
||||
yield
|
||||
finally:
|
||||
self.agent.chat_history = original
|
||||
|
||||
def _execute_with_runtime_resolution(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -519,16 +484,14 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
with self._seed_target_history(prior_messages):
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
|
||||
async def _execute_with_runtime_resolution_async(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full async chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -538,14 +501,13 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
with self._seed_target_history(prior_messages):
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
|
||||
def _check_safety(self, source_agent: 'Agent') -> None:
|
||||
"""
|
||||
@@ -699,9 +661,8 @@ class Handoff:
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context per the configured context_policy/input_filter and
|
||||
# thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context (for future extensibility - context data available for hooks)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt with context
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -716,8 +677,7 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time instead of using construction-time pin
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -809,9 +769,8 @@ class Handoff:
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context per the configured context_policy/input_filter
|
||||
# and thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context (for future extensibility)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -826,8 +785,7 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time for async execution
|
||||
response = await self._execute_with_runtime_resolution_async(
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -940,39 +898,20 @@ class Handoff:
|
||||
# Prepare handoff data with context policy
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Get the last user message or context to pass to target agent.
|
||||
# Track the index of the message chosen for ``prompt`` so exactly
|
||||
# that item is excluded from the seeded history (it may not be the
|
||||
# final list item if an assistant/tool message follows it).
|
||||
# Get the last user message or context to pass to target agent
|
||||
last_message = None
|
||||
selected_index = None
|
||||
for i in range(len(handoff_data.messages) - 1, -1, -1):
|
||||
msg = handoff_data.messages[i]
|
||||
for msg in reversed(handoff_data.messages):
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
last_message = msg.get('content', '')
|
||||
selected_index = i
|
||||
break
|
||||
|
||||
if not last_message and handoff_data.messages:
|
||||
selected_index = len(handoff_data.messages) - 1
|
||||
last_msg = handoff_data.messages[selected_index]
|
||||
last_msg = handoff_data.messages[-1]
|
||||
if isinstance(last_msg, dict):
|
||||
last_message = last_msg.get('content', '')
|
||||
else:
|
||||
last_message = str(last_msg)
|
||||
|
||||
# Tool-style handoff (as_tool): ContextPolicy.NONE discards the
|
||||
# history, so the prompt must come from the generated ``task``/
|
||||
# ``prompt`` argument instead. Without this the sub-agent would
|
||||
# never run and the tool would report a hollow "completed". The
|
||||
# task is the sub-agent's whole instruction, so it is passed
|
||||
# verbatim rather than wrapped in a ``[Handoff from …]`` prefix.
|
||||
task_prompt = None
|
||||
if not last_message and not self.input_type:
|
||||
task_prompt = kwargs.get('task') or kwargs.get('prompt')
|
||||
if task_prompt:
|
||||
last_message = str(task_prompt)
|
||||
|
||||
|
||||
# Prepare context information
|
||||
context_info = f"[Handoff from {source_agent.name}] "
|
||||
if kwargs and self.input_type:
|
||||
@@ -980,26 +919,15 @@ class Handoff:
|
||||
|
||||
# Execute the target agent with tool boundary enforcement
|
||||
if last_message:
|
||||
prompt = last_message if task_prompt else context_info + last_message
|
||||
prompt = context_info + last_message
|
||||
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
|
||||
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Resolve runtime at turn-time for tool function execution.
|
||||
# The selected message is already folded into ``prompt`` above,
|
||||
# so exclude exactly that item from the seeded history to avoid
|
||||
# duplicating it while preserving any messages that follow it.
|
||||
if selected_index is None:
|
||||
prior_messages = list(handoff_data.messages)
|
||||
else:
|
||||
prior_messages = (
|
||||
handoff_data.messages[:selected_index]
|
||||
+ handoff_data.messages[selected_index + 1:]
|
||||
)
|
||||
# Resolve runtime at turn-time for tool function execution
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, prompt, effective_tools, kwargs,
|
||||
prior_messages=prior_messages,
|
||||
source_agent, prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -1067,17 +995,6 @@ class Handoff:
|
||||
)
|
||||
)
|
||||
handoff_tool.__signature__ = inspect.Signature(sig_params)
|
||||
elif self.config.context_policy == ContextPolicy.NONE:
|
||||
# Tool-style handoff (as_tool): no conversation context is passed, so
|
||||
# advertise an explicit ``task`` parameter the caller must fill. This
|
||||
# is what ``handoff_tool`` reads as the sub-agent's prompt above.
|
||||
handoff_tool.__signature__ = inspect.Signature([
|
||||
inspect.Parameter(
|
||||
'task',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=str,
|
||||
)
|
||||
])
|
||||
|
||||
return handoff_tool
|
||||
|
||||
@@ -1551,9 +1468,8 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1580,8 +1496,7 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Typed handoff to {self.agent.name} with validated payload")
|
||||
|
||||
# Execute with timeout if sync
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
response = self.agent.chat(full_prompt)
|
||||
response = self.agent.chat(full_prompt)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -1666,9 +1581,8 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1694,13 +1608,12 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Async typed handoff to {self.agent.name}")
|
||||
|
||||
# Execute - check for async chat method
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
|
||||
return HandoffResult(
|
||||
success=True,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Shared authorisation + bind guard for the ``launch()`` serving paths.
|
||||
|
||||
Both ``Agent.launch()`` (``agent/execution_mixin.py``) and
|
||||
``PraisonAIAgents.launch()`` (``agents/agents.py``) expose a prompt-execution
|
||||
HTTP endpoint. They must enforce identical request authorisation and must not
|
||||
silently bind all interfaces without a token. Factoring the two helpers here
|
||||
keeps that policy in one place so the two launch paths cannot drift apart.
|
||||
|
||||
Stdlib only — no heavy imports, no new public params.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets as _secrets
|
||||
from typing import Optional
|
||||
|
||||
__all__ = [
|
||||
"launch_auth_token",
|
||||
"authorise_launch_request",
|
||||
"resolve_launch_host",
|
||||
]
|
||||
|
||||
# Hosts that are safe to serve keyless on: loopback only.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
# One auto-generated token per process, reused across launches so a later
|
||||
# non-loopback launch cannot mint a fresh token and lock out an endpoint that
|
||||
# is already serving with the previously generated one.
|
||||
_GENERATED_TOKEN: Optional[str] = None
|
||||
|
||||
|
||||
def launch_auth_token() -> Optional[str]:
|
||||
"""Return the configured launch bearer token, if any."""
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def authorise_launch_request(request) -> bool:
|
||||
"""Return True if the request carries the configured bearer token.
|
||||
|
||||
When no token is configured this returns True (local-dev flow preserved).
|
||||
Accepts either ``Authorization: Bearer <token>`` or ``X-Auth-Token``.
|
||||
"""
|
||||
token = launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and _secrets.compare_digest(auth[7:], token):
|
||||
return True
|
||||
supplied = request.headers.get("X-Auth-Token", "")
|
||||
return bool(supplied) and _secrets.compare_digest(supplied, token)
|
||||
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def resolve_launch_host(host: str) -> str:
|
||||
"""Fail-closed bind guard, mirroring the ``serve``/jobs precedent.
|
||||
|
||||
* Token set → bind ``host`` unchanged (auth protects the endpoint).
|
||||
* Token unset + loopback host → bind unchanged (local dev unchanged).
|
||||
* Token unset + non-loopback host → auto-generate a token, export it via
|
||||
``PRAISONAI_LAUNCH_AUTH_TOKEN`` so every route enforces it, and print it
|
||||
once. This closes the unauthenticated-exposure hole without refusing to
|
||||
start.
|
||||
|
||||
Returns the host to bind (unchanged; the guard acts on the token, not the
|
||||
host, so an explicit ``0.0.0.0`` is still honoured — just never keyless).
|
||||
|
||||
A generated token is reused for the lifetime of the process: repeated
|
||||
non-loopback launches share the one token rather than each minting a fresh
|
||||
one, so a later launch cannot invalidate an already-running endpoint's
|
||||
clients.
|
||||
"""
|
||||
if launch_auth_token() or _is_loopback(host):
|
||||
return host
|
||||
|
||||
global _GENERATED_TOKEN
|
||||
if _GENERATED_TOKEN is None:
|
||||
_GENERATED_TOKEN = _secrets.token_urlsafe(32)
|
||||
print(
|
||||
f"🔐 launch() bound to non-loopback host {host!r} without "
|
||||
f"PRAISONAI_LAUNCH_AUTH_TOKEN set. Generated a one-time bearer token "
|
||||
f"(set PRAISONAI_LAUNCH_AUTH_TOKEN to override): {_GENERATED_TOKEN}"
|
||||
)
|
||||
os.environ["PRAISONAI_LAUNCH_AUTH_TOKEN"] = _GENERATED_TOKEN
|
||||
return host
|
||||
@@ -89,8 +89,8 @@ class OCRAgent:
|
||||
ocr: OCR configuration
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "OCRAgent")
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "OCRAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
|
||||
|
||||
Two questions get asked about placement and they have different answers:
|
||||
|
||||
``run_on=``
|
||||
Where the **whole agent** runs -- the model calls, the loop, the tools, all
|
||||
of it on someone else's machine. Only a *managed runtime* can answer this,
|
||||
because running the loop remotely means something has to host the loop.
|
||||
|
||||
``tools_run_on=``
|
||||
Where **the tools** run. Thinking stays on this machine; only the shell,
|
||||
the file writes and the code execution move. Any sandbox or compute place
|
||||
can answer this, because all it has to do is run a command.
|
||||
|
||||
Keeping the two words apart is the entire point. The old surface used one word
|
||||
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
|
||||
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
|
||||
(``backend=``) for the whole-agent meaning -- so the same concept had two names
|
||||
and one name had two concepts.
|
||||
|
||||
Because the two questions have disjoint answer sets, a wrong pairing is a typo
|
||||
worth catching rather than a preference worth honouring. ``run_on="docker"``
|
||||
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
|
||||
naming the parameter the user actually wanted, not a silent fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, NamedTuple, Optional, Union
|
||||
|
||||
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
|
||||
# entry-point registry when it is installed so third-party backends work with
|
||||
# `run_on=` too; the literal is the floor, not the ceiling.
|
||||
_FALLBACK_MANAGED = ("anthropic",)
|
||||
|
||||
# Static spellings so editors can autocomplete and type-checkers can reject a
|
||||
# typo at the call site rather than at the first model turn. The runtime
|
||||
# functions below stay the authority for validation -- these exist for tooling,
|
||||
# which cannot call a function to learn a set.
|
||||
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
|
||||
ManagedRuntime = Literal["anthropic"]
|
||||
|
||||
ToolPlace = Literal[
|
||||
"local", "subprocess", "sandlock", "docker", "e2b",
|
||||
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
|
||||
]
|
||||
|
||||
|
||||
def managed_runtimes() -> tuple:
|
||||
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
|
||||
try:
|
||||
from praisonai.integrations.backend_registry import get_backend_registry
|
||||
|
||||
names = tuple(get_backend_registry().list_all_names())
|
||||
if names:
|
||||
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
|
||||
except Exception:
|
||||
pass
|
||||
return _FALLBACK_MANAGED
|
||||
|
||||
|
||||
def tool_places() -> list:
|
||||
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
|
||||
|
||||
Includes the resolver's aliases. Validation runs before resolution, so
|
||||
leaving them out rejected spellings the resolver handles happily:
|
||||
``tools_run_on="native"`` raised "not a known place" while
|
||||
``resolve_compute("native")`` returned a working sandlock adapter.
|
||||
"""
|
||||
try:
|
||||
from ..managed._compute_bridge import _ALIASES, available_providers
|
||||
|
||||
return sorted(set(available_providers()) | set(_ALIASES))
|
||||
except Exception:
|
||||
return ["local", "docker"]
|
||||
|
||||
|
||||
class Placement(NamedTuple):
|
||||
"""The resolved answer to "where does this run?"."""
|
||||
|
||||
whole: Optional[Any] = None # managed runtime name/object, or None
|
||||
tools: Optional[Any] = None # tools place name/object, or None
|
||||
|
||||
|
||||
def _name_of(value: Any) -> Optional[str]:
|
||||
"""The comparable name of a placement value, or None if it is an object.
|
||||
|
||||
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
|
||||
are never second-guessed -- only bare strings get routed by name.
|
||||
"""
|
||||
return value.lower() if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _quote_list(names) -> str:
|
||||
return ", ".join(repr(n) for n in names)
|
||||
|
||||
|
||||
def resolve_placement(
|
||||
owner: str,
|
||||
*,
|
||||
run_on: Any = None,
|
||||
tools_run_on: Any = None,
|
||||
backend: Any = None,
|
||||
supports_run_on: bool = True,
|
||||
) -> Placement:
|
||||
"""Validate placement arguments and return where things should run.
|
||||
|
||||
``owner`` is the class name, used only to make errors read naturally.
|
||||
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
|
||||
but cannot (yet) hand a whole multi-agent run to a managed runtime.
|
||||
|
||||
Raises:
|
||||
TypeError: on any combination that names two different places for the
|
||||
same thing, or a place that cannot do the job asked of it.
|
||||
"""
|
||||
managed = managed_runtimes()
|
||||
places = tool_places()
|
||||
|
||||
# ── a place that cannot do the job asked of it ───────────────────────────
|
||||
run_on_name = _name_of(run_on)
|
||||
if run_on_name is not None and run_on_name not in managed:
|
||||
if run_on_name in places:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
|
||||
f"whole agent -- model calls, loop and tools -- on a managed "
|
||||
f"runtime, and {run_on_name!r} runs commands but cannot host an "
|
||||
f"agent loop.\n"
|
||||
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)}"
|
||||
)
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
|
||||
f" Places that can run tools: {_quote_list(places)} "
|
||||
f"(pass those as tools_run_on=)"
|
||||
)
|
||||
|
||||
tools_name = _name_of(tools_run_on)
|
||||
if tools_name is not None and tools_name in managed and tools_name not in places:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
|
||||
f"hosts an entire agent, not individual tools.\n"
|
||||
f" Did you mean: {owner}(run_on={tools_run_on!r})"
|
||||
)
|
||||
|
||||
# A typo here used to survive construction and only surface on the first
|
||||
# model turn -- after the prompt was built and, for a real run, after the
|
||||
# API spend. run_on= validates at the call site and YAML validates at load
|
||||
# time; this is the same standard for the third spelling.
|
||||
if tools_name is not None and tools_name not in places:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
|
||||
f" Places that can run tools: {_quote_list(places)}\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)} "
|
||||
f"(pass those as run_on=)"
|
||||
)
|
||||
|
||||
# Names that cannot work as a bare string, because the connection details
|
||||
# live in the object. Catch it now rather than at first use.
|
||||
if tools_name is not None:
|
||||
try:
|
||||
from ..managed._sandbox_adapter import NEEDS_INSTANCE
|
||||
except Exception:
|
||||
NEEDS_INSTANCE = {}
|
||||
if tools_name in NEEDS_INSTANCE:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
|
||||
f"{NEEDS_INSTANCE[tools_name]}"
|
||||
)
|
||||
|
||||
# ── two names for the same thing ─────────────────────────────────────────
|
||||
if run_on is not None and backend is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
|
||||
f"run_on={run_on!r} is the short form of backend=HostedAgent("
|
||||
f"provider={run_on!r}); pass one or the other.\n"
|
||||
f" Use backend= only when you need to configure the runtime "
|
||||
f"(model, system prompt, tools)."
|
||||
)
|
||||
|
||||
if run_on is not None and tools_run_on is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
|
||||
f"the tools at two machines. run_on= already runs everything -- "
|
||||
f"including tools -- on {run_on!r}.\n"
|
||||
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
|
||||
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
|
||||
)
|
||||
|
||||
if backend is not None and tools_run_on is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
|
||||
f"tools at two machines: backend= runs the whole agent (tools "
|
||||
f"included) on its managed runtime, so the tools cannot also run on "
|
||||
f"{tools_run_on!r}.\n"
|
||||
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
|
||||
f"to keep local thinking with remote tools."
|
||||
)
|
||||
|
||||
# ── a class that cannot answer this question ─────────────────────────────
|
||||
if run_on is not None and not supports_run_on:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
|
||||
f"agent's whole loop to a managed runtime, and a {owner} "
|
||||
f"orchestrates several agents locally.\n"
|
||||
f" To run every step's tools in one shared sandbox:\n"
|
||||
f" {owner}(tools_run_on={run_on!r})\n"
|
||||
f" To host a single agent's loop, put run_on= on that Agent."
|
||||
)
|
||||
|
||||
return Placement(whole=run_on, tools=tools_run_on)
|
||||
@@ -57,8 +57,7 @@ class RealtimeAgent:
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "RealtimeAgent",
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None,
|
||||
llm: Optional[str] = None,
|
||||
realtime: Optional[Union[bool, Dict, RealtimeConfig]] = None,
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = True,
|
||||
@@ -68,16 +67,14 @@ class RealtimeAgent:
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Model to use (default: gpt-4o-realtime-preview)
|
||||
llm: LLM model (default: gpt-4o-realtime-preview)
|
||||
realtime: Realtime configuration (bool, dict, or RealtimeConfig)
|
||||
instructions: System instructions
|
||||
verbose: Enable verbose output
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.name = name
|
||||
self.llm = resolve_model_name(llm, model, "RealtimeAgent") or "gpt-4o-realtime-preview"
|
||||
self.llm = llm or "gpt-4o-realtime-preview"
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
@@ -18,36 +18,18 @@ except ImportError: # pragma: no cover - py<3.8 fallback
|
||||
|
||||
if Literal is not None:
|
||||
TerminalReason = Literal[
|
||||
"completed",
|
||||
"hard_timeout",
|
||||
"cancelled",
|
||||
"aborted",
|
||||
"failed",
|
||||
"content_filtered",
|
||||
"refused",
|
||||
"length_truncated",
|
||||
"completed", "hard_timeout", "cancelled", "aborted", "failed"
|
||||
]
|
||||
else: # pragma: no cover
|
||||
TerminalReason = str # type: ignore
|
||||
|
||||
# Provider-side terminal reasons derived from the LLM ``finish_reason``/refusal
|
||||
# signal. Additive and backward-compatible: absent/unknown finish reasons keep
|
||||
# the existing ``completed|failed|...`` semantics unchanged.
|
||||
PROVIDER_BLOCK_REASONS = ("content_filtered", "refused", "length_truncated")
|
||||
|
||||
# Precedence: higher wins and is sticky (a hard timeout is not downgraded).
|
||||
# A specific provider block/refusal/truncation outranks a generic ``failed`` so
|
||||
# the actionable reason is not masked, but stays below cancellation/timeout,
|
||||
# which are host-level lifecycle signals.
|
||||
_REASON_PRECEDENCE = {
|
||||
"completed": 0,
|
||||
"failed": 1,
|
||||
"content_filtered": 2,
|
||||
"refused": 2,
|
||||
"length_truncated": 2,
|
||||
"aborted": 3,
|
||||
"cancelled": 4,
|
||||
"hard_timeout": 5,
|
||||
"aborted": 2,
|
||||
"cancelled": 3,
|
||||
"hard_timeout": 4,
|
||||
}
|
||||
|
||||
|
||||
@@ -111,27 +93,3 @@ class RunOutcome:
|
||||
def _name_matches(exc: BaseException, needles: tuple) -> bool:
|
||||
name = type(exc).__name__.lower()
|
||||
return any(n in name for n in needles)
|
||||
|
||||
|
||||
def classify_finish_reason(finish_reason, refusal=None):
|
||||
"""Map a provider ``finish_reason``/refusal signal to a terminal reason.
|
||||
|
||||
Returns one of ``content_filtered | refused | length_truncated`` when the
|
||||
provider blocked/refused/truncated the turn, or ``None`` for a normal stop
|
||||
(``None``/``"stop"``) or any unrecognised value — so unknown finish reasons
|
||||
behave exactly as today. Additive and zero-cost on the success path.
|
||||
"""
|
||||
if refusal:
|
||||
return "refused"
|
||||
if not finish_reason:
|
||||
return None
|
||||
fr = str(finish_reason).lower()
|
||||
if fr in ("stop", "tool_calls", "function_call"):
|
||||
return None
|
||||
if "content_filter" in fr or fr == "content_filtered":
|
||||
return "content_filtered"
|
||||
if "refus" in fr:
|
||||
return "refused"
|
||||
if fr == "length" or "max_tokens" in fr or "truncat" in fr:
|
||||
return "length_truncated"
|
||||
return None
|
||||
@@ -52,61 +52,19 @@ class SandboxMixin:
|
||||
|
||||
return self._sandbox_manager
|
||||
|
||||
|
||||
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
|
||||
"""Return the sandbox manager for a ``run_in=`` argument.
|
||||
|
||||
``run_in=`` names where a single ``execute_code()`` call runs, so the
|
||||
place lives on the call that uses it rather than on the constructor.
|
||||
``Agent(sandbox=...)`` still sets the default for every call.
|
||||
|
||||
Managers are cached per place: a fresh ``SandboxManager`` per call would
|
||||
start and tear down a container each time, which for Docker is seconds
|
||||
of latency and for any backend means the previous call's files are gone.
|
||||
"""
|
||||
if run_in is None:
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Either name a place on the call --\n"
|
||||
" agent.execute_code_sync(code, run_in='docker')\n"
|
||||
"or set a default for every call with Agent(sandbox='docker')."
|
||||
)
|
||||
return self.get_sandbox_manager()
|
||||
|
||||
from ..sandbox import SandboxConfig, SandboxManager
|
||||
|
||||
if run_in is True:
|
||||
config = SandboxConfig.subprocess()
|
||||
elif isinstance(run_in, str):
|
||||
config = SandboxConfig(sandbox_type=run_in)
|
||||
else:
|
||||
config = run_in
|
||||
|
||||
key = getattr(config, "sandbox_type", None) or repr(config)
|
||||
cache = getattr(self, "_sandbox_managers", None)
|
||||
if cache is None:
|
||||
cache = self._sandbox_managers = {}
|
||||
if key not in cache:
|
||||
cache[key] = SandboxManager(config)
|
||||
return cache[key]
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Execute code safely in a sandbox.
|
||||
"""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
|
||||
run_in: Where to run this call -- "docker", "sandlock",
|
||||
"subprocess", a SandboxConfig, or True for the default local
|
||||
sandbox. Overrides Agent(sandbox=...) for this call only.
|
||||
**kwargs: Additional arguments passed to sandbox.execute()
|
||||
|
||||
Returns:
|
||||
@@ -115,7 +73,10 @@ class SandboxMixin:
|
||||
Raises:
|
||||
RuntimeError: If no sandbox is configured
|
||||
"""
|
||||
manager = self._manager_for(run_in)
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
|
||||
)
|
||||
|
||||
# Security pre-check if enabled
|
||||
security_warnings = None
|
||||
@@ -127,6 +88,7 @@ class SandboxMixin:
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for code execution:\n{warning_text}")
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
result = await manager.run_code(code, language=language, **kwargs)
|
||||
|
||||
# Attach warnings to the result rather than forwarding a `metadata`
|
||||
@@ -147,7 +109,6 @@ class SandboxMixin:
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Synchronous wrapper for execute_code.
|
||||
@@ -162,15 +123,12 @@ class SandboxMixin:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
return run_coroutine_safely(
|
||||
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
|
||||
)
|
||||
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,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Run a shell command in the sandbox.
|
||||
@@ -183,7 +141,8 @@ class SandboxMixin:
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
manager = self._manager_for(run_in)
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError("No sandbox configured")
|
||||
|
||||
# Convert command to string for security checking
|
||||
if isinstance(command, list):
|
||||
@@ -200,7 +159,7 @@ class SandboxMixin:
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for command:\n{warning_text}")
|
||||
|
||||
async with manager as sandbox:
|
||||
async with self.get_sandbox_manager() as sandbox:
|
||||
return await sandbox.run_command(command, **kwargs)
|
||||
|
||||
def get_sandbox_status(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -374,12 +374,6 @@ class ToolExecutionMixin:
|
||||
|
||||
return resolve_tool_names(tool_names)
|
||||
|
||||
def _resolve_tools_list(self, tools):
|
||||
"""Resolve any tool-name strings inside a mixed ``tools=`` list."""
|
||||
from ..tools.resolver import resolve_tools_list
|
||||
|
||||
return resolve_tools_list(tools)
|
||||
|
||||
def _cast_arguments(self, func, arguments):
|
||||
"""Cast arguments to their expected types based on function signature."""
|
||||
if not callable(func) or not arguments:
|
||||
@@ -495,7 +489,6 @@ class ToolExecutionMixin:
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -753,21 +746,26 @@ class ToolExecutionMixin:
|
||||
if blocked_result is not None:
|
||||
result = blocked_result
|
||||
else:
|
||||
# Apply tool retry logic with exponential backoff.
|
||||
# ONE vocabulary: the effective RetryPolicy caps how many times
|
||||
# the tool body may run, whichever of the two loops does the
|
||||
# retrying, and owns the backoff. ExecutionConfig.max_retry_limit
|
||||
# / retry_* are translated into it (see
|
||||
# _effective_tool_retry_policy) rather than run as a second,
|
||||
# independent budget in different units.
|
||||
retry_policy = self._effective_tool_retry_policy(function_name)
|
||||
max_attempts = retry_policy.max_attempts
|
||||
|
||||
# Apply tool retry logic with exponential backoff
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is None:
|
||||
# Fall back to reading individual config attributes for backward compatibility
|
||||
max_retry_limit = getattr(self, 'max_retry_limit', 2)
|
||||
retry_initial_delay = 1.0
|
||||
retry_backoff_factor = 2.0
|
||||
retry_jitter = 0.1
|
||||
else:
|
||||
max_retry_limit = execution_config.max_retry_limit
|
||||
retry_initial_delay = execution_config.retry_initial_delay
|
||||
retry_backoff_factor = execution_config.retry_backoff_factor
|
||||
retry_jitter = execution_config.retry_jitter
|
||||
|
||||
result = None
|
||||
last_exception = None
|
||||
|
||||
# max_attempts is the total number of tool-body runs.
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
|
||||
# max_retry_limit is the number of retries (not total attempts)
|
||||
# So total attempts = 1 (initial) + max_retry_limit (retries)
|
||||
for attempt in range(1, max_retry_limit + 2):
|
||||
try:
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
@@ -809,12 +807,7 @@ class ToolExecutionMixin:
|
||||
_progress_active.clear()
|
||||
future.cancel()
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
# Tag as an OUTER wall-clock timeout: unlike an
|
||||
# inner-loop timeout (already retried to
|
||||
# exhaustion), the body ran once here and the
|
||||
# inner budget never saw it, so this one still
|
||||
# earns an outer retry pass under the single budget.
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True, "_outer_timeout": True}
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
# The timed-out thread cannot be reclaimed. Retire this
|
||||
# executor so the next call gets a fresh worker instead of
|
||||
# queuing behind a permanently stuck one — but bound how many
|
||||
@@ -833,101 +826,59 @@ class ToolExecutionMixin:
|
||||
|
||||
# Check if the result indicates a retryable error
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
# Approval/permission/policy/guardrail denials are
|
||||
# legitimate non-retryable outcomes: stop immediately.
|
||||
if result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
|
||||
# Check if this is a circuit breaker error (always retryable)
|
||||
if result.get("circuit_open"):
|
||||
raise ToolExecutionError(
|
||||
result["error"],
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# Check if this is a timeout error (retryable)
|
||||
elif result.get("timeout"):
|
||||
raise ToolExecutionError(
|
||||
result["error"],
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# For other error dicts: approval/permission denials are legitimate
|
||||
# non-retryable outcomes; everything else represents a tool failure
|
||||
# that should engage the outer retry/backoff loop.
|
||||
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
|
||||
break
|
||||
# ONE budget: the inner loop
|
||||
# (_execute_tool_with_circuit_breaker) already retries
|
||||
# its retry_on error types (timeout/rate_limit/
|
||||
# connection_error) to exhaustion before returning here,
|
||||
# so re-escalating them as retryable would restart the
|
||||
# whole inner budget on every outer attempt and run the
|
||||
# tool up to max_attempts² times.
|
||||
error_type = self._classify_error_type(result, None)
|
||||
inner_policy = self._get_tool_retry_policy(function_name)
|
||||
# Only escalate to an outer retry pass when the failure is
|
||||
# a transient the inner loop did NOT already re-drive:
|
||||
# • _outer_timeout — this loop's own wall-clock guard
|
||||
# tripped; the body ran once and the inner budget
|
||||
# never saw it. BUT the timed-out body is still
|
||||
# running in an orphaned thread (future.cancel() is
|
||||
# a no-op once started), so retrying a mutating tool
|
||||
# would duplicate its side effects. Only re-drive it
|
||||
# when the tool is known idempotent/read-only.
|
||||
# • circuit_open — returned by the inner loop WITHOUT a
|
||||
# body run, so retrying costs no duplicate side effect.
|
||||
# • _praison_retryable — a raised exception was flattened
|
||||
# into this error dict (see _execute_tool_impl); honour
|
||||
# that verdict, but ONLY for error types the inner loop
|
||||
# does not itself retry, or inner×outer would run the
|
||||
# body max_attempts² times.
|
||||
# A plain error dict the tool body *returned* carries no tag
|
||||
# — it is the tool's own reported outcome, not a transient
|
||||
# the framework must re-drive. The body already ran to
|
||||
# completion, so re-running it would duplicate its side
|
||||
# effects (charge money, send a message, write a row) for a
|
||||
# decision the tool already made. If a user genuinely wants
|
||||
# such an outcome retried, its error type belongs in the
|
||||
# RetryPolicy.retry_on the inner loop honours — not a word
|
||||
# in the tool's own payload. Surface it once.
|
||||
is_retryable = bool(
|
||||
(result.get("_outer_timeout")
|
||||
and self._is_tool_idempotent(function_name))
|
||||
or result.get("circuit_open")
|
||||
or (result.get("_praison_retryable") is True
|
||||
and error_type not in inner_policy.retry_on
|
||||
# The body already ran and may have completed
|
||||
# its side effect before raising. Honour an
|
||||
# explicit non-idempotent declaration here, as
|
||||
# the _outer_timeout clause above already does.
|
||||
and not self._tool_declares_not_idempotent(function_name))
|
||||
)
|
||||
# A tool that *raised* is flattened into an error dict
|
||||
# tagged with ``_praison_retryable`` (see
|
||||
# _execute_tool_impl). A raised exception is a
|
||||
# framework-level tool FAILURE — it must surface as a
|
||||
# ToolExecutionError so the run is finalized failed and
|
||||
# a genuine RuntimeError/SMTP-down/rate-limit-text tool
|
||||
# is never silently downgraded to a success (parity with
|
||||
# the base behaviour and test_tool_failure_not_relabelled).
|
||||
# ``is_retryable`` above decides only whether the outer
|
||||
# loop retries, not whether it raises.
|
||||
raised_exception = "_praison_retryable" in result
|
||||
# Strip the private control-plane tag before it can reach
|
||||
# the model or be re-surfaced as the tool's payload.
|
||||
result.pop("_praison_retryable", None)
|
||||
if is_retryable or raised_exception:
|
||||
else:
|
||||
# Avoid compounding with the inner retry loop in
|
||||
# _execute_tool_with_circuit_breaker: error types it already
|
||||
# retries (e.g. timeout/rate_limit/connection_error) are
|
||||
# exhausted by the time they reach here, so escalating them as
|
||||
# retryable would re-run the entire inner loop from scratch on
|
||||
# every outer attempt. Only escalate error types the inner loop
|
||||
# does NOT retry (e.g. "unknown") so they get one outer pass.
|
||||
error_type = self._classify_error_type(result, None)
|
||||
inner_policy = self._get_tool_retry_policy(function_name)
|
||||
is_retryable = error_type not in inner_policy.retry_on
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
# A plain tool-authored {"error": ...} with none of the
|
||||
# transient/denial markers and no raised-exception tag is
|
||||
# the tool's own documented way of *returning* a
|
||||
# recoverable failure (see tools/shell_tools.py and the
|
||||
# other bundled tools). It is the tool's answer, not a
|
||||
# framework-level crash — hand it back as a normal tool
|
||||
# result so the LLM can see it and self-correct, instead
|
||||
# of aborting the whole run.
|
||||
break
|
||||
else:
|
||||
# Success path - return the result
|
||||
break
|
||||
|
||||
except ToolExecutionError as e:
|
||||
last_exception = e
|
||||
# Only retry if the error is marked as retryable and the
|
||||
# policy still has attempts left.
|
||||
if not e.is_retryable or attempt >= max_attempts:
|
||||
# Only retry if the error is marked as retryable and we have retries left
|
||||
# attempt starts at 1, so (attempt - 1) gives us the retry count
|
||||
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
|
||||
raise e
|
||||
|
||||
# Calculate delay for exponential backoff (policy owns it)
|
||||
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
|
||||
|
||||
# Calculate delay for exponential backoff
|
||||
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
|
||||
logging.warning(
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
|
||||
f"Retrying in {delay:.2f}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
@@ -943,14 +894,15 @@ class ToolExecutionMixin:
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
last_exception = tool_error
|
||||
|
||||
if not is_retryable or attempt >= max_attempts:
|
||||
|
||||
# attempt starts at 1, so (attempt - 1) gives us the retry count
|
||||
if not is_retryable or (attempt - 1) >= max_retry_limit:
|
||||
raise tool_error from e
|
||||
|
||||
# Calculate delay for exponential backoff (policy owns it)
|
||||
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
|
||||
|
||||
# Calculate delay for exponential backoff
|
||||
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
|
||||
logging.warning(
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
|
||||
f"Retrying in {delay:.2f}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
@@ -1647,12 +1599,6 @@ class ToolExecutionMixin:
|
||||
or (trust_level == "external") # External tools need approval
|
||||
)
|
||||
if needs_approval:
|
||||
# Attach a rendered unified diff for file-mutating tools so the
|
||||
# reviewer approves the actual change rather than a truncated
|
||||
# argument dump. Non-edit tools get no diff (``None``) and keep
|
||||
# the existing argument summary.
|
||||
from ..approval.utils import build_diff_preview
|
||||
diff_preview = build_diff_preview(tool_name, tool_args)
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=tool_args,
|
||||
@@ -1661,7 +1607,6 @@ class ToolExecutionMixin:
|
||||
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
|
||||
),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
context={"diff": diff_preview} if diff_preview else {},
|
||||
)
|
||||
|
||||
if is_async:
|
||||
@@ -1727,32 +1672,15 @@ class ToolExecutionMixin:
|
||||
# registry state (which leaked onto other agents when this agent had
|
||||
# no stable name), pass the intent per-call via ``force`` so the
|
||||
# registry prompts for *this* call only without side effects.
|
||||
# Skill auto-approval grants are keyed by this agent's unique
|
||||
# per-instance scope id (not the display name, which defaults to
|
||||
# "Agent" for every unnamed agent), so a skill grant on one agent
|
||||
# can never unlock the tool for an unrelated agent in the process.
|
||||
auto_approve_scope = getattr(self, '_approval_scope_id', None)
|
||||
# Pass the per-instance scope id as ``scope_id`` too, so the
|
||||
# in-memory "this session" grant store is keyed per Agent instance
|
||||
# rather than by the display name (every unnamed Agent() defaults to
|
||||
# "Agent"). Without this, one worker's human-granted "[s] this
|
||||
# session" approval silently unlocks the same tool for a distinct
|
||||
# same-named worker. ``self.name`` remains the ``agent_name`` for the
|
||||
# name-keyed lookups (per-agent backend, risk level, ask rules,
|
||||
# durable "always" persistence).
|
||||
if is_async:
|
||||
return get_approval_registry().approve_async(
|
||||
getattr(self, 'name', None), tool_name, tool_args,
|
||||
force=manager_forces_approval,
|
||||
auto_approve_scope=auto_approve_scope,
|
||||
scope_id=auto_approve_scope,
|
||||
)
|
||||
else:
|
||||
return get_approval_registry().approve_sync(
|
||||
getattr(self, 'name', None), tool_name, tool_args,
|
||||
force=manager_forces_approval,
|
||||
auto_approve_scope=auto_approve_scope,
|
||||
scope_id=auto_approve_scope,
|
||||
)
|
||||
|
||||
def _doom_loop_approved(self, function_name, arguments, verdict) -> bool:
|
||||
@@ -1810,17 +1738,11 @@ class ToolExecutionMixin:
|
||||
function_name, arguments
|
||||
),
|
||||
}
|
||||
# Key the "this session" grant by the per-instance scope id so a
|
||||
# doom-loop continue granted on one worker cannot silently authorise
|
||||
# a same-named sibling worker, matching the per-instance scoping used
|
||||
# for regular tool approvals above. ``self.name`` stays as agent_name
|
||||
# for the name-keyed lookups.
|
||||
decision = registry.approve_sync(
|
||||
getattr(self, "name", None),
|
||||
DOOM_LOOP_TARGET,
|
||||
request_args,
|
||||
force=True,
|
||||
scope_id=getattr(self, "_approval_scope_id", None),
|
||||
)
|
||||
return bool(getattr(decision, "approved", False))
|
||||
except Exception as e: # noqa: BLE001 — fail closed to the block path
|
||||
@@ -2009,7 +1931,7 @@ class ToolExecutionMixin:
|
||||
)
|
||||
return any(marker in name for marker in edit_markers)
|
||||
|
||||
def _check_permission_manager_deny(self, function_name, arguments=None):
|
||||
def _check_permission_manager_deny(self, function_name):
|
||||
"""Return an error dict if the PermissionManager hard-denies the tool.
|
||||
|
||||
Ensures the pattern-based ``PermissionManager`` (rules from
|
||||
@@ -2017,51 +1939,18 @@ class ToolExecutionMixin:
|
||||
uniformly — native functions **and** MCP tools — since both flow through
|
||||
``_execute_tool_impl``. Returns ``None`` when no manager is attached or
|
||||
the tool is not denied, preserving backward compatibility.
|
||||
|
||||
Both the tool *name* and the argument-scoped target (e.g.
|
||||
``bash:rm -rf /tmp/x`` built from ``execute_command(command=...)``) are
|
||||
checked so an argument-level ``deny`` rule — which the rule engine can
|
||||
already match — actually fires. Without this the deny tier was a
|
||||
name-only decision: once a shell tool was allowed by name, no rule could
|
||||
narrow it. The argument target reuses ``build_permission_target`` so it
|
||||
speaks the same vocabulary as the allow side's scoped grants.
|
||||
"""
|
||||
manager = getattr(self, "_permission_manager", None)
|
||||
if manager is None:
|
||||
return None
|
||||
agent_name = getattr(self, "name", None)
|
||||
targets = [function_name]
|
||||
if arguments:
|
||||
try:
|
||||
from ..approval.utils import build_permission_target
|
||||
# Normalise argument keys the same way ``_cast_arguments`` does
|
||||
# before dispatch (strip trailing '='/whitespace LLMs hallucinate)
|
||||
# so ``command=`` still resolves to the ``command`` command-key
|
||||
# and an argument-scoped deny (``bash:rm *``) cannot be evaded by
|
||||
# a malformed kwarg name that only normalises *after* this gate.
|
||||
normalized_args = arguments
|
||||
if isinstance(arguments, dict):
|
||||
normalized_args = {
|
||||
(k.strip().rstrip('=').strip() if isinstance(k, str) else k): v
|
||||
for k, v in arguments.items()
|
||||
}
|
||||
scoped_target = build_permission_target(function_name, normalized_args)
|
||||
if scoped_target and scoped_target != function_name:
|
||||
targets.append(scoped_target)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.debug(
|
||||
"could not build argument-scoped permission target for %s: %s",
|
||||
function_name, e,
|
||||
)
|
||||
for target in targets:
|
||||
try:
|
||||
if manager.is_denied(target, agent_name):
|
||||
return {
|
||||
"error": f"Tool '{function_name}' blocked by permission policy",
|
||||
"permission_denied": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logging.debug("permission manager is_denied failed for %s: %s", target, e)
|
||||
try:
|
||||
if manager.is_denied(function_name, getattr(self, "name", None)):
|
||||
return {
|
||||
"error": f"Tool '{function_name}' blocked by permission policy",
|
||||
"permission_denied": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logging.debug("permission manager is_denied failed for %s: %s", function_name, e)
|
||||
return None
|
||||
|
||||
def _is_bypass_mode(self) -> bool:
|
||||
@@ -2096,7 +1985,7 @@ class ToolExecutionMixin:
|
||||
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
|
||||
# BYPASS mode skips this gate as its contract requires.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
manager_denial = self._check_permission_manager_deny(function_name)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
@@ -2105,30 +1994,15 @@ class ToolExecutionMixin:
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
feedback = getattr(decision, "feedback", None)
|
||||
if feedback:
|
||||
error_msg = (
|
||||
f"Tool '{function_name}' was denied by the user. "
|
||||
f"Do not retry it as-is. User guidance: {feedback}"
|
||||
)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
# Re-authorize: an approval backend may have rewritten the arguments
|
||||
# into a command that an argument-scoped deny rule prohibits. The
|
||||
# earlier gate only saw the original args, so re-run it against the
|
||||
# final ones before dispatch. BYPASS still opts out entirely.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
function_name, arguments, agent_name=getattr(self, "name", None),
|
||||
scope_id=getattr(self, "_approval_scope_id", None),
|
||||
function_name, arguments, agent_name=getattr(self, "name", None)
|
||||
)
|
||||
return None, arguments
|
||||
|
||||
@@ -2143,7 +2017,7 @@ class ToolExecutionMixin:
|
||||
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
|
||||
# BYPASS mode skips this gate as its contract requires.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
manager_denial = self._check_permission_manager_deny(function_name)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
@@ -2153,28 +2027,15 @@ class ToolExecutionMixin:
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
feedback = getattr(decision, "feedback", None)
|
||||
if feedback:
|
||||
error_msg = (
|
||||
f"Tool '{function_name}' was denied by the user. "
|
||||
f"Do not retry it as-is. User guidance: {feedback}"
|
||||
)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
# Re-authorize modified args against the deny gate — see the sync
|
||||
# path for rationale. BYPASS still opts out entirely.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
function_name, arguments, agent_name=getattr(self, "name", None),
|
||||
scope_id=getattr(self, "_approval_scope_id", None),
|
||||
function_name, arguments, agent_name=getattr(self, "name", None)
|
||||
)
|
||||
return None, arguments
|
||||
|
||||
@@ -2208,13 +2069,7 @@ class ToolExecutionMixin:
|
||||
result.get("guardrail_denied") or
|
||||
result.get("circuit_open")):
|
||||
return result
|
||||
|
||||
# The body already ran and may have completed its side effect
|
||||
# before failing. Honour an explicit non-idempotent declaration
|
||||
# rather than re-driving it (send a second email, charge twice).
|
||||
if self._tool_declares_not_idempotent(function_name):
|
||||
return result
|
||||
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
@@ -2319,83 +2174,6 @@ class ToolExecutionMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_tool_circuit_breaker(self, function_name):
|
||||
"""Return this agent's circuit breaker for *function_name*, or None.
|
||||
|
||||
Shared between the sync tool path (_execute_tool_with_circuit_breaker_impl)
|
||||
and the async tool path (_execute_tool_async_impl) so both engage the
|
||||
same per-agent breaker. Uses the identical instance-scoped key so one
|
||||
agent's failing tool cannot trip another agent's same-named tool.
|
||||
Returns None when the circuit_breaker module is unavailable.
|
||||
"""
|
||||
try:
|
||||
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig
|
||||
except ImportError:
|
||||
return None
|
||||
breaker_name = f"tool_{id(self)}_{function_name}"
|
||||
config = CircuitBreakerConfig(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
timeout=30.0,
|
||||
graceful_degradation=True,
|
||||
)
|
||||
breaker = get_circuit_breaker(breaker_name, config)
|
||||
self._register_breaker_finalizer(breaker_name)
|
||||
return breaker
|
||||
|
||||
def _circuit_breaker_open_result(self, function_name):
|
||||
"""Return the standard ``circuit_open`` error dict for *function_name*."""
|
||||
return {
|
||||
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
|
||||
"circuit_open": True,
|
||||
"agent_name": getattr(self, "name", None),
|
||||
"session_id": getattr(self, "_session_id", None),
|
||||
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
|
||||
}
|
||||
|
||||
def _circuit_breaker_precheck(self, function_name):
|
||||
"""Short-circuit an OPEN breaker before running the tool body.
|
||||
|
||||
Mirrors the OPEN-state rejection logic of ``CircuitBreaker.call`` so the
|
||||
async tool path gets the same protection as the sync path without needing
|
||||
to funnel its async ``_invoke`` through the sync ``breaker.call``.
|
||||
|
||||
Returns a ``circuit_open`` error dict when the call must be rejected, or
|
||||
None when the tool is allowed to run (and mutates the breaker into
|
||||
HALF_OPEN when the recovery timeout has elapsed, exactly like ``call``).
|
||||
"""
|
||||
breaker = self._get_tool_circuit_breaker(function_name)
|
||||
if breaker is None:
|
||||
return None
|
||||
try:
|
||||
from ..tools.circuit_breaker import CircuitState
|
||||
except ImportError:
|
||||
return None
|
||||
with breaker._lock:
|
||||
breaker._stats.total_requests += 1
|
||||
if breaker._stats.state == CircuitState.OPEN:
|
||||
if not breaker._should_attempt_reset():
|
||||
breaker._stats.rejected_requests += 1
|
||||
return self._circuit_breaker_open_result(function_name)
|
||||
breaker._stats.state = CircuitState.HALF_OPEN
|
||||
breaker._stats.success_count = 0
|
||||
return None
|
||||
|
||||
def _circuit_breaker_record(self, function_name, is_success):
|
||||
"""Record a tool outcome against this agent's breaker.
|
||||
|
||||
Used by the async path after ``_invoke`` so repeated failures open the
|
||||
breaker just as the sync path's ``breaker.call`` does. Treated as a
|
||||
no-op when the breaker is unavailable.
|
||||
"""
|
||||
breaker = self._get_tool_circuit_breaker(function_name)
|
||||
if breaker is None:
|
||||
return
|
||||
if is_success:
|
||||
breaker._on_success()
|
||||
else:
|
||||
breaker._on_failure()
|
||||
|
||||
def _execute_tool_with_circuit_breaker_impl(self, function_name, arguments):
|
||||
"""Execute tool with circuit breaker protection (internal implementation).
|
||||
|
||||
@@ -2773,20 +2551,8 @@ class ToolExecutionMixin:
|
||||
f"{error_msg} Expected parameters for '{function_name}' — "
|
||||
f"required: {schema['required']}, optional: {schema['optional']}."
|
||||
)
|
||||
# An argument-binding error is a programming error — the same
|
||||
# call will always fail — so mark it terminal and never retry.
|
||||
return {"error": error_msg, "expected_parameters": schema, "_praison_retryable": False}
|
||||
# A tool that *raised* is flattened into an error dict here, which
|
||||
# erases the exception type the retry policy needs. Carry the same
|
||||
# verdict the exception paths use so the outer loop can retry a
|
||||
# transient failure without re-running a tool that merely *returned*
|
||||
# an error dict of its own. Programming errors are terminal;
|
||||
# everything else may be retried. Private key: never leaks to the
|
||||
# model and cannot collide with a tool's own "retryable" field.
|
||||
return {
|
||||
"error": error_msg,
|
||||
"_praison_retryable": not isinstance(e, (ValueError, TypeError, AttributeError)),
|
||||
}
|
||||
return {"error": error_msg, "expected_parameters": schema}
|
||||
return {"error": error_msg}
|
||||
|
||||
# Unresolved: return a corrective, model-readable message so the model can
|
||||
# retry with a valid name instead of repeating the same mistake.
|
||||
@@ -2982,15 +2748,12 @@ class ToolExecutionMixin:
|
||||
|
||||
from ..approval.protocols import ApprovalRequest
|
||||
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
|
||||
from ..approval.utils import build_diff_preview
|
||||
|
||||
diff_preview = build_diff_preview(function_name, arguments)
|
||||
request = ApprovalRequest(
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
context={"diff": diff_preview} if diff_preview else {},
|
||||
)
|
||||
|
||||
tracking_id = str(uuid.uuid4())[:8]
|
||||
@@ -3176,133 +2939,17 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
|
||||
def _tool_declares_not_idempotent(self, tool_name, tools_override=None):
|
||||
"""True only when the tool *explicitly* declares itself unsafe to re-run.
|
||||
|
||||
Deliberately narrower than ``_is_tool_idempotent``, which answers False
|
||||
for unknown tools as a safe default: gating a retry on that would stop
|
||||
retrying every unregistered user tool.
|
||||
|
||||
This keys on an explicit declaration and nothing else. In particular it
|
||||
must NOT consult the shared ``MUTATING_TOOLS`` name registry -- that is a
|
||||
73-name list maintained for the escalation loop guard, and using it here
|
||||
silently removed the retry from any *undeclared* tool that happened to
|
||||
share a name with an entry (``write_file``, ``store_memory``, ``mkdir``...).
|
||||
|
||||
``tools_override`` mirrors the resolution order in ``_execute_tool_impl`` /
|
||||
``_execute_tool_async_impl``: a task-scoped tool passed via ``tools_override``
|
||||
shadows an agent-level tool of the same name, so its declaration must win.
|
||||
Without this an override-only ``restart_safe=False`` tool would be missed
|
||||
here and silently retried.
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
if isinstance(tools_override, (list, tuple)):
|
||||
# Override tools take precedence and are searched first.
|
||||
tools = list(tools_override) + list(tools)
|
||||
for tool in tools:
|
||||
name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if not isinstance(explicit, bool):
|
||||
# ``restart_safe`` is the public replay-safety contract on
|
||||
# ``@tool`` (FunctionTool) and ``BaseTool``: ``False`` marks an
|
||||
# effectful tool that must never be silently re-executed.
|
||||
explicit = getattr(tool, 'restart_safe', None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
return False
|
||||
# A tool from the process-global registry (only reachable under
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own declaration;
|
||||
# ``_is_tool_idempotent`` consults it, so this must too.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
for attr in ('idempotent', 'restart_safe'):
|
||||
explicit = getattr(global_tool, attr, None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_tool_idempotent(self, tool_name):
|
||||
"""Whether re-running ``tool_name`` is safe (no duplicated side effects).
|
||||
|
||||
Used to decide if an OUTER wall-clock timeout may be retried: the timed-out
|
||||
tool body is still running in an orphaned thread (``future.cancel()`` is a
|
||||
no-op once started), so auto-retrying a mutating tool would duplicate its
|
||||
side effects (send a second email, charge twice). Only tools classified as
|
||||
read-only/idempotent are safe to re-drive.
|
||||
|
||||
Precedence: explicit ``idempotent`` attribute on the tool > the shared
|
||||
read-only/mutating name registry (reused from the escalation loop guard).
|
||||
Unknown tools default to NOT idempotent (safe: surface the timeout once).
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
for tool in tools:
|
||||
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if tool_name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
break
|
||||
# A tool from the process-global registry (only reachable when
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own explicit
|
||||
# ``idempotent`` flag even when its name is absent from the shared
|
||||
# read-only/mutating registries below.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
explicit = getattr(global_tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from ..escalation.loop_guard import IDEMPOTENT_TOOLS, MUTATING_TOOLS
|
||||
except Exception:
|
||||
return False
|
||||
if tool_name in IDEMPOTENT_TOOLS:
|
||||
return True
|
||||
if tool_name in MUTATING_TOOLS:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _get_tool_retry_policy(self, tool_name):
|
||||
"""Get retry policy for a tool (tool-level > agent-level > default).
|
||||
|
||||
Kept as the historical name; both retry loops now go through
|
||||
``_effective_tool_retry_policy`` so they share one policy object.
|
||||
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to get retry policy for
|
||||
|
||||
Returns:
|
||||
RetryPolicy instance
|
||||
"""
|
||||
return self._effective_tool_retry_policy(tool_name)
|
||||
|
||||
def _effective_tool_retry_policy(self, tool_name):
|
||||
"""The one RetryPolicy that governs every retry of ``tool_name``.
|
||||
|
||||
Precedence: per-tool ``.retry_policy`` > agent-level
|
||||
``ToolConfig(retry_policy=...)`` > a policy translated from the
|
||||
ExecutionConfig spelling of the same knobs.
|
||||
|
||||
``ExecutionConfig`` speaks a second vocabulary for the same thing --
|
||||
``max_retry_limit`` counts *retries* (so total runs = limit + 1),
|
||||
``retry_initial_delay`` is in *seconds*, and ``retry_jitter`` is a
|
||||
*fraction*. Translating it here, once, is what keeps the two retry loops
|
||||
from running two independent budgets in two different units.
|
||||
"""
|
||||
from ..tools.retry import RetryPolicy
|
||||
|
||||
|
||||
# Check for tool-level retry policy first
|
||||
tools = getattr(self, 'tools', [])
|
||||
# Handle non-iterable tools (e.g., single MCP instance)
|
||||
@@ -3313,43 +2960,16 @@ class ToolExecutionMixin:
|
||||
tool_policy = getattr(tool, 'retry_policy', None) if hasattr(tool, 'retry_policy') else None
|
||||
if tool_name_attr == tool_name and tool_policy is not None:
|
||||
return tool_policy
|
||||
|
||||
|
||||
# Check for agent-level retry policy
|
||||
agent_policy = getattr(self, '_tool_retry_policy', None)
|
||||
if agent_policy is not None:
|
||||
return agent_policy
|
||||
|
||||
# Translate the ExecutionConfig spelling into the RetryPolicy vocabulary.
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is None:
|
||||
max_retry_limit = getattr(self, 'max_retry_limit', 2)
|
||||
initial_delay_s, backoff_factor, jitter_fraction = 1.0, 2.0, 0.1
|
||||
else:
|
||||
max_retry_limit = execution_config.max_retry_limit
|
||||
initial_delay_s = execution_config.retry_initial_delay
|
||||
backoff_factor = execution_config.retry_backoff_factor
|
||||
jitter_fraction = execution_config.retry_jitter
|
||||
|
||||
cached = getattr(self, '_translated_retry_policy', None)
|
||||
key = (max_retry_limit, initial_delay_s, backoff_factor, jitter_fraction)
|
||||
if cached is not None and cached[0] == key:
|
||||
return cached[1]
|
||||
# Reproduce BackoffPolicy.delay's 60s base cap so ExecutionConfig users
|
||||
# keep the exact same delay ceiling they had before this consolidation
|
||||
# (RetryPolicy defaults to a 30s cap). initial_delay_ms must not exceed
|
||||
# max_delay_ms per RetryPolicy validation, so lift the cap to whichever
|
||||
# is larger.
|
||||
initial_delay_ms = int(initial_delay_s * 1000)
|
||||
policy = RetryPolicy(
|
||||
max_attempts=max(1, max_retry_limit + 1),
|
||||
backoff_factor=max(1.0, backoff_factor),
|
||||
initial_delay_ms=initial_delay_ms,
|
||||
max_delay_ms=max(60000, initial_delay_ms),
|
||||
jitter=jitter_fraction > 0,
|
||||
jitter_factor=min(max(jitter_fraction, 0.0), 1.0),
|
||||
)
|
||||
self._translated_retry_policy = (key, policy)
|
||||
return policy
|
||||
|
||||
# Return default retry policy (cached class-level instance)
|
||||
if not hasattr(ToolExecutionMixin, '_default_retry_policy'):
|
||||
ToolExecutionMixin._default_retry_policy = RetryPolicy()
|
||||
return ToolExecutionMixin._default_retry_policy
|
||||
|
||||
def _classify_error_type(self, error_dict, exception):
|
||||
"""Classify error type for retry policy matching.
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Give a single Agent's tools a sandbox, without touching its thinking.
|
||||
|
||||
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
|
||||
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
|
||||
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
|
||||
many times -- so the sandbox is created on first use and lives as long as the
|
||||
agent does. That is deliberate: a file written in turn 1 is still there in turn
|
||||
2, which is what anyone who asked for a working directory expects.
|
||||
|
||||
Attachment is idempotent and happens at the top of ``chat``/``achat``/
|
||||
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
|
||||
snapshotted. Doing it once, rather than wrapping each call in a context
|
||||
manager, sidesteps two real hazards in this codebase:
|
||||
|
||||
* ``_start_stream`` is a **generator**; a context manager around the call would
|
||||
exit before the caller consumed a single token, tearing down the sandbox while
|
||||
the model was still using it.
|
||||
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
|
||||
itself and provision a second sandbox.
|
||||
|
||||
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
|
||||
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
|
||||
reference each other, so a dropped agent was reclaimed by the cycle collector,
|
||||
vanished from the set, and its sandbox was never shut down. Gateways clone an
|
||||
agent **per request**, so that was one orphaned container per request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# One lock per agent, so two unrelated agents never wait on each other and no
|
||||
# lock is ever held while another agent's provider is being imported. Held only
|
||||
# by ensure_tools_placed/close_tools_sandbox for that one agent.
|
||||
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
|
||||
_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _lock_for(agent: Any) -> threading.Lock:
|
||||
"""The placement lock for ``agent``, created on demand.
|
||||
|
||||
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
|
||||
provider construction or attachment. An earlier version held one global lock
|
||||
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
|
||||
agents serialised behind each other, and a slow import could stall
|
||||
interpreter exit.
|
||||
"""
|
||||
with _LOCKS_GUARD:
|
||||
lock = _AGENT_LOCKS.get(agent)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
try:
|
||||
_AGENT_LOCKS[agent] = lock
|
||||
except TypeError: # pragma: no cover - unhashable/non-weakrefable
|
||||
pass
|
||||
return lock
|
||||
|
||||
|
||||
def _shutdown_orphan(shared: Any, name: str) -> None:
|
||||
"""Tear down a sandbox whose agent is gone.
|
||||
|
||||
Registered with ``weakref.finalize``, so it runs when the agent is collected
|
||||
*and* at interpreter exit. It must not close over the agent -- that would
|
||||
keep it alive and defeat the whole point.
|
||||
"""
|
||||
try:
|
||||
shared.shutdown()
|
||||
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
|
||||
except Exception as exc: # pragma: no cover - teardown must stay quiet
|
||||
# Loud enough to notice: a failed release means a container, or a
|
||||
# billed cloud instance, is still running after the agent is gone.
|
||||
logger.warning(
|
||||
"[tools_placement] could not release the sandbox for %s: %s", name, exc
|
||||
)
|
||||
|
||||
|
||||
def ensure_tools_placed(agent: Any) -> None:
|
||||
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
|
||||
|
||||
Called at the top of every entry point, so it must be cheap when there is
|
||||
nothing to do -- which is the overwhelmingly common case.
|
||||
"""
|
||||
place = getattr(agent, "tools_run_on", None)
|
||||
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
|
||||
return
|
||||
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
with _lock_for(agent):
|
||||
# Re-check under the lock: two threads calling chat() at once must not
|
||||
# each provision a sandbox and leak one.
|
||||
if getattr(agent, "_tools_sandbox", None) is not None:
|
||||
return
|
||||
|
||||
shared = SharedCompute(place)
|
||||
# override_declared: this sandbox exists *because* the agent named a
|
||||
# place, so the skip-if-declared rule must not skip the agent itself.
|
||||
shared.attach([agent], override_declared=True)
|
||||
agent._tools_sandbox = shared
|
||||
agent._tools_sandbox_finalizer = weakref.finalize(
|
||||
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
|
||||
)
|
||||
|
||||
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
|
||||
|
||||
|
||||
def close_tools_sandbox(agent: Any) -> None:
|
||||
"""Tear down the agent's sandbox and put its original tools back."""
|
||||
with _lock_for(agent):
|
||||
# Everything happens under the lock. Clearing the marker first and
|
||||
# shutting down afterwards left a window where a concurrent chat()
|
||||
# re-attached and the in-flight shutdown then restored over it, leaving
|
||||
# the agent marked as sandboxed while holding no sandbox tools -- a
|
||||
# state ensure_tools_placed() could never repair, because its idempotent
|
||||
# early return sees the marker and does nothing.
|
||||
shared = getattr(agent, "_tools_sandbox", None)
|
||||
if shared is None:
|
||||
return
|
||||
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
|
||||
if finalizer is not None:
|
||||
finalizer.detach() # we are shutting down explicitly
|
||||
agent._tools_sandbox_finalizer = None
|
||||
shared.shutdown()
|
||||
agent._tools_sandbox = None
|
||||
@@ -282,7 +282,7 @@ class UnifiedExecutionMixin:
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
)
|
||||
else:
|
||||
# Standard LiteLLM path - delegate to existing LLM class
|
||||
@@ -304,7 +304,7 @@ class UnifiedExecutionMixin:
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
)
|
||||
|
||||
# Store response in memory if enabled
|
||||
|
||||
@@ -93,25 +93,10 @@ class VideoAgent:
|
||||
Supported Providers:
|
||||
- OpenAI: openai/sora-2, openai/sora-2-pro
|
||||
- Azure: azure/sora-2, azure/sora-2-pro
|
||||
- Gemini Veo 3.1 (paid API, uses GOOGLE_API_KEY / GEMINI_API_KEY):
|
||||
- gemini/veo-3.1-generate-preview # Standard / cinematic quality
|
||||
- gemini/veo-3.1-fast-generate-preview # Fast
|
||||
- gemini/veo-3.1-lite-generate-preview # Lite (cheapest iteration)
|
||||
- Vertex AI Veo 3.1 (uses GOOGLE_APPLICATION_CREDENTIALS or VERTEXAI_PROJECT/VERTEXAI_LOCATION):
|
||||
- vertex_ai/veo-3.1-generate-001 # GA Standard
|
||||
- vertex_ai/veo-3.1-fast-generate-001 # GA Fast
|
||||
- vertex_ai/veo-3.1-lite-generate-001 # Lite
|
||||
- Gemini: gemini/veo-3.0-generate-preview, gemini/veo-3.1-*
|
||||
- Vertex AI: vertex_ai/veo-3.0-*, vertex_ai/veo-3.1-*
|
||||
- RunwayML: runwayml/gen4_turbo (requires input_reference)
|
||||
|
||||
Deprecated (do NOT recommend; kept working only if LiteLLM still routes them):
|
||||
- gemini/veo-2.0-generate-001 (Gemini API shutdown 30 Jun 2026)
|
||||
- gemini/veo-3.0-generate-preview, gemini/veo-3.0-fast-generate-preview
|
||||
- vertex_ai/veo-3.0-* (migrate to the veo-3.1 IDs above)
|
||||
|
||||
Note on pricing: Gemini/Vertex Veo bill in USD per generated second via the paid
|
||||
API (charged only on success) and are a different meter from Flow UI credits — a
|
||||
Flow subscription cannot pay for API generations. See praison.ai/docs/video/gemini.
|
||||
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import VideoAgent
|
||||
@@ -129,7 +114,7 @@ class VideoAgent:
|
||||
|
||||
# With config
|
||||
agent = VideoAgent(
|
||||
llm="gemini/veo-3.1-generate-preview",
|
||||
llm="gemini/veo-3.0-generate-preview",
|
||||
video=VideoConfig(seconds="8", size="1280x720")
|
||||
)
|
||||
```
|
||||
@@ -161,7 +146,7 @@ class VideoAgent:
|
||||
Args:
|
||||
name: Agent name for identification
|
||||
instructions: Optional instructions (unused for video generation)
|
||||
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.1-generate-preview")
|
||||
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.0-generate-preview")
|
||||
model: Alias for llm= parameter
|
||||
base_url: Custom API endpoint URL
|
||||
api_key: API key for the provider
|
||||
@@ -173,8 +158,9 @@ class VideoAgent:
|
||||
verbose: Verbosity level for output
|
||||
output: Output preset ("silent", "verbose", etc.)
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "VideoAgent")
|
||||
# Handle model= alias for llm=
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
# Store core identity
|
||||
self.name = name or "VideoAgent"
|
||||
@@ -315,12 +301,8 @@ class VideoAgent:
|
||||
prompt: Text description of the desired video
|
||||
seconds: Video duration (e.g., "8", "16"). Defaults to config value.
|
||||
size: Video dimensions (e.g., "720x1280"). Defaults to config value.
|
||||
input_reference: Reference image for image-to-video (first-frame) generation
|
||||
**kwargs: Additional provider-specific parameters, forwarded to LiteLLM.
|
||||
For Veo 3.1 interpolation/reference features, pass them here (no
|
||||
dedicated params are added to keep the surface lightweight):
|
||||
- last_frame=<image> # first + last frame (Veo 3.1 only)
|
||||
- reference_images=[...] # up to 3 reference images
|
||||
input_reference: Reference image for image-to-video generation
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
VideoObject with id, status, and metadata
|
||||
@@ -334,14 +316,6 @@ class VideoAgent:
|
||||
)
|
||||
print(f"Video ID: {video.id}")
|
||||
print(f"Status: {video.status}")
|
||||
|
||||
# Veo 3.1 first + last frame interpolation (via kwargs passthrough)
|
||||
veo = VideoAgent(llm="gemini/veo-3.1-generate-preview")
|
||||
veo.generate(
|
||||
prompt="Sunrise timelapse",
|
||||
input_reference=start_image, # first frame
|
||||
last_frame=end_image, # last frame (Veo 3.1)
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Merge config with kwargs
|
||||
|
||||
@@ -112,8 +112,8 @@ class VisionAgent:
|
||||
- VisionConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "VisionAgent")
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "VisionAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -2,8 +2,6 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import contextlib
|
||||
import contextvars
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Any, Dict, Optional, List, Tuple, Callable
|
||||
@@ -43,13 +41,18 @@ class TaskStatus(Enum):
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Request authorisation + fail-closed bind guard are shared with Agent.launch
|
||||
# so the single-agent and multi-agent serving paths cannot drift apart.
|
||||
from ..agent.launch_security import (
|
||||
launch_auth_token as _launch_auth_token,
|
||||
authorise_launch_request as _authorise_launch_request,
|
||||
resolve_launch_host as _resolve_launch_host,
|
||||
)
|
||||
def _launch_auth_token() -> Optional[str]:
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def _authorise_launch_request(request) -> bool:
|
||||
token = _launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
|
||||
|
||||
# Agent server registry for thread-safe server management
|
||||
@@ -372,18 +375,6 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
if llm and hasattr(llm, 'set_current_agent'):
|
||||
llm.set_current_agent(executor_agent.display_name)
|
||||
|
||||
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
|
||||
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
|
||||
# the sandbox attaches later (inside chat()), the model is handed the
|
||||
# pre-attach list and every tool runs on the host while a sandbox sits
|
||||
# provisioned and unused. Silent, and worse than it sounds: in
|
||||
# start_for_each() row 1 got local tools and row 2 got the bridged set,
|
||||
# because row 1's chat() mutated agent.tools in time for row 2's snapshot.
|
||||
if executor_agent is not None and getattr(executor_agent, "tools_run_on", None) is not None:
|
||||
from ..agent.tools_placement import ensure_tools_placed
|
||||
|
||||
ensure_tools_placed(executor_agent)
|
||||
|
||||
# Ensure tools are available from both task and agent (create copy to avoid mutation)
|
||||
tools = list(task.tools or [])
|
||||
if executor_agent and executor_agent.tools:
|
||||
@@ -653,8 +644,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
manager_llm=None,
|
||||
name: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None, # Default model for members that did not name one
|
||||
llm: Optional[str] = None, # Default LLM for all agents (API consistency)
|
||||
# Consolidated feature params (agent-centric API)
|
||||
memory: Optional[Any] = False, # Union[bool, MultiAgentMemoryConfig]
|
||||
planning: Optional[Any] = False, # Union[bool, MultiAgentPlanningConfig]
|
||||
@@ -672,12 +662,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
|
||||
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
|
||||
# "local", "subprocess", "sandlock", "ssh", "novita". The team shares
|
||||
# ONE sandbox and one working directory, so files written by one agent
|
||||
# are visible to the others. Orchestration and thinking stay local.
|
||||
# "local". The team shares ONE sandbox and /workspace, so files written
|
||||
# by one agent are visible to the others. Orchestration stays local.
|
||||
# Pass a configured provider instance to customise resources.
|
||||
tools_run_on: Optional[Any] = None,
|
||||
# Accepted only to fail with a useful message -- see resolve_placement.
|
||||
run_on: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
@@ -689,11 +676,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
process: Execution process type ("sequential", "workflow", "hierarchical").
|
||||
For parallel fan-out, set async_execution=True on individual Task
|
||||
objects within a "workflow" or "sequential" process.
|
||||
manager_llm: Model for the hierarchical manager agent. Unrelated to
|
||||
model=/llm=; it never touches the members.
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Default model for members that did not name one themselves.
|
||||
An agent constructed with its own llm=/model=/auth= keeps it.
|
||||
manager_llm: LLM model for manager agent
|
||||
llm: Default LLM model for all agents
|
||||
name: Name for this agent collection
|
||||
variables: Global variables for substitution
|
||||
memory: Memory configuration (bool | MultiAgentMemoryConfig)
|
||||
@@ -711,21 +695,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
# model= is the canonical name; llm= is the deprecated alias. Passing
|
||||
# both is refused rather than silently resolved (see resolve_model_alias).
|
||||
# The resolved value is the default model for the members: applied to
|
||||
# every agent that never named a model of its own; an agent that did
|
||||
# keeps it. Same rule AgentFlow uses for the agents it builds.
|
||||
# Must UNWRAP, not just alias-resolve: this value is pushed into every
|
||||
# member via _apply_default_llm, and a member holding an LLMConfig fails
|
||||
# inside chat() with "'LLMConfig' object has no attribute 'lower'".
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.llm = resolve_model_name(llm, model, type(self).__name__)
|
||||
if self.llm:
|
||||
for _member in (agents.values() if isinstance(agents, dict) else (agents or [])):
|
||||
_apply = getattr(_member, "_apply_default_llm", None)
|
||||
if callable(_apply):
|
||||
_apply(self.llm)
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
self._guardrails = guardrails
|
||||
@@ -940,19 +910,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self.stream = _stream
|
||||
self.name = name
|
||||
# Remote execution: one shared sandbox for every agent on this team.
|
||||
from ..agent.placement import resolve_placement
|
||||
|
||||
resolve_placement(
|
||||
"AgentTeam", run_on=run_on, tools_run_on=tools_run_on,
|
||||
supports_run_on=False,
|
||||
)
|
||||
self.tools_run_on = tools_run_on
|
||||
# Kept readable rather than removed. `team.run_on` used to exist, and
|
||||
# code doing getattr(team, "run_on", None) for introspection or
|
||||
# serialisation would otherwise start raising AttributeError. None is
|
||||
# the honest value: a team orchestrates locally, so nothing about it
|
||||
# runs wholly on a managed runtime.
|
||||
self.run_on = None
|
||||
self.run_on = run_on
|
||||
|
||||
# Callbacks for workflow execution
|
||||
self.on_task_start = _on_task_start
|
||||
@@ -1338,28 +1296,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
|
||||
return task_output, True # Signal retry needed
|
||||
|
||||
async def _aapply_task_guardrail(self, task, task_id, task_output):
|
||||
"""Async wrapper for _apply_task_guardrail.
|
||||
|
||||
A string/LLMGuardrail guardrail fires a *blocking* LLM call inside
|
||||
_apply_task_guardrail. Offload it to a thread so it does not stall the
|
||||
event loop (and every other task running concurrently under
|
||||
asyncio.gather in arun_all_tasks/astart), mirroring the executor-offload
|
||||
pattern used for synchronous memory calls in async_memory_mixin.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Preserve contextvars (trace emission, session context) across the
|
||||
# executor thread so a custom task guardrail sees the same contextual
|
||||
# state as the synchronous path, matching every other run_in_executor
|
||||
# call site in this module.
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
copy_context_to_callable(
|
||||
lambda: self._apply_task_guardrail(task, task_id, task_output)
|
||||
),
|
||||
)
|
||||
|
||||
def _run_task_start_hook(self, task, task_id):
|
||||
"""Run the on_task_start hook and propagate global variables to the task.
|
||||
|
||||
@@ -1439,9 +1375,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status in ["not started", "in progress"]:
|
||||
task_output = await self.aexecute_task(task_id)
|
||||
if task_output and self.completion_checker(task, task_output.raw):
|
||||
# Apply guardrail validation using shared helper (offloaded to
|
||||
# a thread so a blocking LLM guardrail does not stall the loop)
|
||||
task_output, should_retry = await self._aapply_task_guardrail(task, task_id, task_output)
|
||||
# Apply guardrail validation using shared helper
|
||||
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
|
||||
if should_retry:
|
||||
retries += 1
|
||||
continue
|
||||
@@ -1502,87 +1437,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
raise result
|
||||
return results
|
||||
|
||||
def _depends_on_pending(self, task, pending):
|
||||
"""True if ``task`` depends on a task still pending in the batch.
|
||||
|
||||
``pending`` is a list of ``(task_id, coroutine)`` pairs already queued
|
||||
for parallel execution. If one of the task's dependencies is in that
|
||||
set, its result isn't available yet, so the batch must be flushed before
|
||||
queuing this task (otherwise the dependent builds its prompt from an
|
||||
empty result for the still-in-progress dependency).
|
||||
|
||||
Two dependency edges are checked, since ``_build_task_context`` reads
|
||||
from both:
|
||||
- ``task.context``: explicit context tasks (by object ``id``).
|
||||
- ``task.previous_tasks``: workflow ``next_tasks`` edges, stored as
|
||||
task *names*; without this a workflow successor could be queued in
|
||||
the same async batch as its predecessor and read an empty result.
|
||||
"""
|
||||
if not pending:
|
||||
return False
|
||||
pending_ids = {tid for tid, _ in pending}
|
||||
|
||||
deps = getattr(task, 'context', None) or []
|
||||
if any(getattr(dep, 'id', None) in pending_ids for dep in deps):
|
||||
return True
|
||||
|
||||
previous = getattr(task, 'previous_tasks', None) or []
|
||||
if previous:
|
||||
pending_names = {
|
||||
self.tasks[tid].name
|
||||
for tid in pending_ids
|
||||
if tid in self.tasks
|
||||
}
|
||||
if any(name in pending_names for name in previous):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _deps_failed(self, task_id):
|
||||
"""True if any dependency of ``task_id`` finished in a failed state.
|
||||
|
||||
asequential()/aworkflow() run their own failure check inside the
|
||||
generator, but async tasks are buffered and drained later, so an
|
||||
upstream async task may not have failed yet when the generator yielded
|
||||
the dependent. Re-checking here — after pending async tasks are flushed
|
||||
— makes the failure cascade fire for the async_execution path too, for
|
||||
both dependency edges ``_build_task_context`` reads (context tasks and
|
||||
workflow ``previous_tasks``).
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
for dep in getattr(task, 'context', None) or []:
|
||||
dep_id = getattr(dep, 'id', None)
|
||||
if dep_id in self.tasks and self.tasks[dep_id].status == "failed":
|
||||
return True
|
||||
|
||||
previous = getattr(task, 'previous_tasks', None) or []
|
||||
if previous:
|
||||
by_name = {t.name: t for t in self.tasks.values()}
|
||||
if any(
|
||||
name in by_name and by_name[name].status == "failed"
|
||||
for name in previous
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _should_continue_on_dep_failure(self, task_id):
|
||||
"""True if the task opted into graceful degradation via its own flags.
|
||||
|
||||
Honors the documented per-task flow-control knobs ``skip_on_failure``
|
||||
and ``on_error`` (``"continue"``). When set, a dependent task still runs
|
||||
(in degraded mode) instead of being force-failed when an upstream
|
||||
dependency fails.
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
return bool(getattr(task, 'skip_on_failure', False)) or \
|
||||
getattr(task, 'on_error', 'stop') == 'continue'
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
process = Process(
|
||||
@@ -1594,27 +1448,14 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
)
|
||||
|
||||
if self.process == "workflow":
|
||||
tasks_to_run = [] # list of (task_id, coroutine)
|
||||
tasks_to_run = []
|
||||
async for task_id in process.aworkflow():
|
||||
task = self.tasks[task_id]
|
||||
if task.async_execution:
|
||||
# If this async task depends on another async task still
|
||||
# pending in the current batch, flush first so its context
|
||||
# is available before this one reads it.
|
||||
if self._depends_on_pending(task, tasks_to_run):
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
tasks_to_run = []
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context
|
||||
# unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
if self.tasks[task_id].async_execution:
|
||||
tasks_to_run.append(self.arun_task(task_id))
|
||||
else:
|
||||
# If we encounter a sync task, we must wait for the previous async tasks to finish.
|
||||
if tasks_to_run:
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
tasks_to_run = []
|
||||
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -1624,42 +1465,46 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
|
||||
|
||||
if tasks_to_run:
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
|
||||
elif self.process == "sequential":
|
||||
async_tasks_to_run = [] # list of (task_id, coroutine)
|
||||
async_tasks_to_run = []
|
||||
|
||||
async def flush_async_tasks():
|
||||
"""Execute all pending async tasks"""
|
||||
nonlocal async_tasks_to_run
|
||||
if async_tasks_to_run:
|
||||
await self._gather_with_isolation([c for _, c in async_tasks_to_run])
|
||||
await self._gather_with_isolation(async_tasks_to_run)
|
||||
async_tasks_to_run = []
|
||||
|
||||
async for task_id in process.asequential():
|
||||
def _deps_failed(task_id):
|
||||
"""Re-check dependency failure at consumption time.
|
||||
|
||||
asequential() runs this check inside the generator, but async
|
||||
tasks are buffered and drained later, so an upstream async task
|
||||
may not have failed yet when the generator yielded the dependent.
|
||||
Re-checking here — after pending async tasks are flushed — makes
|
||||
the failure cascade fire for the async_execution path too.
|
||||
"""
|
||||
task = self.tasks[task_id]
|
||||
if task.async_execution:
|
||||
# If this async task depends on another async task still
|
||||
# pending in the current batch, flush first so its context
|
||||
# is available before this one reads it (avoids a same-batch
|
||||
# race that silently substitutes empty dependency context).
|
||||
if self._depends_on_pending(task, async_tasks_to_run):
|
||||
await flush_async_tasks()
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context
|
||||
# unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
if getattr(task, 'context', None):
|
||||
return any(
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context
|
||||
if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
return False
|
||||
|
||||
async for task_id in process.asequential():
|
||||
if self.tasks[task_id].async_execution:
|
||||
# Collect async tasks to run in parallel
|
||||
async_tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
async_tasks_to_run.append(self.arun_task(task_id))
|
||||
else:
|
||||
# Before running a sync task, execute all pending async tasks
|
||||
await flush_async_tasks()
|
||||
# A just-flushed async prerequisite may now be failed; skip
|
||||
# the dependent so we don't run it with missing upstream
|
||||
# context, unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
# the dependent so we don't run it with missing upstream context.
|
||||
if _deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -1699,62 +1544,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
|
||||
# ── shared sandbox lifetime ──────────────────────────────────────────────
|
||||
# A ContextVar, not an instance attribute: the previous version set
|
||||
# `self.run_on = None` for the duration of the run, so a second caller
|
||||
# inspecting the team mid-run saw "no sandbox" and any concurrent start()
|
||||
# executed every tool on the host. Depth lives with the caller, not on the
|
||||
# shared object, so the team's declared placement is always readable.
|
||||
#
|
||||
# The depth is keyed PER TEAM, not shared class-wide. A single integer here
|
||||
# meant any team started while another team's run was on the stack saw
|
||||
# depth==1 and skipped its own sandbox entirely -- so a nested team ran on
|
||||
# the host, and a team sharing an agent with the outer run executed on the
|
||||
# OUTER team's sandbox.
|
||||
#
|
||||
# ContextVar, not threading.local: two coroutines that `astart()` the SAME
|
||||
# team under one asyncio.gather run on one thread, so thread-local state is
|
||||
# shared between them -- the second saw the first's depth==1, skipped its
|
||||
# own sandbox, and ran against a sandbox the first could tear down mid-flight.
|
||||
# A ContextVar is copied per task at gather/create_task time, so each run
|
||||
# gets its own depth map; it is also per-thread, so sync callers stay
|
||||
# isolated too. The value is an immutable frozen mapping replaced via .set()
|
||||
# (never mutated in place), because a copied context shares the *reference*
|
||||
# to a mutable dict and in-place mutation would leak straight back across
|
||||
# the tasks the copy was meant to separate.
|
||||
_tools_scope_depths: "contextvars.ContextVar" = contextvars.ContextVar(
|
||||
"praisonai_tools_scope_depths", default=()
|
||||
)
|
||||
|
||||
def _needs_tools_scope(self) -> bool:
|
||||
"""True when this call should provision the team's shared sandbox."""
|
||||
if getattr(self, "tools_run_on", None) is None:
|
||||
return False
|
||||
return dict(self._tools_scope_depths.get()).get(id(self), 0) == 0
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _shared_tools_scope(self):
|
||||
"""Hold one sandbox for everything nested inside.
|
||||
|
||||
Re-entrant by design: ``start_for_each`` opens the scope once for the
|
||||
whole batch, so the ``start()`` call it makes per input row finds the
|
||||
scope already open and reuses that sandbox instead of provisioning and
|
||||
destroying one per row.
|
||||
"""
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
key = id(self)
|
||||
depths = dict(self._tools_scope_depths.get())
|
||||
depths[key] = depths.get(key, 0) + 1
|
||||
token = self._tools_scope_depths.set(tuple(sorted(depths.items())))
|
||||
try:
|
||||
with SharedCompute(self.tools_run_on) as shared:
|
||||
shared.attach(list(self.agents or []))
|
||||
yield shared
|
||||
finally:
|
||||
self._tools_scope_depths.reset(token)
|
||||
|
||||
async def astart(self, content=None, return_dict=False, **kwargs):
|
||||
"""Async version of start method.
|
||||
|
||||
@@ -1763,12 +1552,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
return_dict: If True, returns the full results dictionary instead of only the final response
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
# Same shared sandbox as start(). Without this, an async team with
|
||||
# tools_run_on= ran every tool on the host and said nothing.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return await self.astart(content=content, return_dict=return_dict, **kwargs)
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
self._telemetry.track_agent_execution(self.name, success=True, async_mode=True)
|
||||
@@ -2029,11 +1812,19 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
```
|
||||
"""
|
||||
# Remote execution: provision ONE sandbox shared by every agent on the
|
||||
# team, and tear it down even if execution raises.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return self.start(content=content, return_dict=return_dict,
|
||||
output=output, **kwargs)
|
||||
# team, and tear it down even if execution raises. Re-enters start()
|
||||
# with run_on cleared so the wrapper applies exactly once.
|
||||
if getattr(self, "run_on", None) is not None:
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
run_on, self.run_on = self.run_on, None
|
||||
try:
|
||||
with SharedCompute(run_on) as shared:
|
||||
shared.attach(list(self.agents or []))
|
||||
return self.start(content=content, return_dict=return_dict,
|
||||
output=output, **kwargs)
|
||||
finally:
|
||||
self.run_on = run_on
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
@@ -2349,15 +2140,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
# One sandbox for the whole batch. Without this the per-row start()
|
||||
# call provisions and destroys its own, so a 50-row batch paid for 50
|
||||
# sandboxes and no row could see what an earlier row wrote.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return self.start_for_each(
|
||||
inputs, on_error=on_error, output=output, **kwargs
|
||||
)
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
@@ -2394,13 +2176,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
# One sandbox for the whole batch -- see start_for_each.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return await self.astart_for_each(
|
||||
inputs, on_error=on_error, **kwargs
|
||||
)
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
@@ -2610,110 +2385,21 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
return False
|
||||
|
||||
def _own_agent_names(self) -> set:
|
||||
"""Names of this instance's own agents, for token-report scoping."""
|
||||
return {
|
||||
getattr(agent, "name", None)
|
||||
for agent in (self.agents or [])
|
||||
if getattr(agent, "name", None)
|
||||
}
|
||||
|
||||
def _scoped_recent_interactions(self, own_names: set) -> List[Dict[str, Any]]:
|
||||
"""Return this instance's own recorded interactions, most-recent last.
|
||||
|
||||
The process-wide collector keeps a bounded window of every agent's
|
||||
interactions; filter it to this instance's agent names so per-model
|
||||
totals, interaction counts, and the detailed report never leak another
|
||||
concurrent instance's records.
|
||||
"""
|
||||
try:
|
||||
interactions = get_token_collector().get_recent_interactions(
|
||||
limit=get_token_collector()._max_recent
|
||||
)
|
||||
except Exception:
|
||||
interactions = get_token_collector().get_recent_interactions(limit=100)
|
||||
return [i for i in interactions if i.get("agent") in own_names]
|
||||
|
||||
def _scoped_token_summary(self) -> Dict[str, Any]:
|
||||
"""Return the global token summary filtered to this instance's agents.
|
||||
|
||||
The process-wide TokenCollector aggregates every agent in the process,
|
||||
so two concurrent PraisonAIAgents instances would otherwise read each
|
||||
other's totals. Scope every reported field — ``by_agent``, ``by_model``,
|
||||
``total_interactions`` and the totals — to this instance's own agent
|
||||
names so per-instance/per-tenant cost accounting reports only its own
|
||||
spend. ``by_agent`` is taken from the collector's aggregate breakdown;
|
||||
``by_model`` and ``total_interactions`` are re-derived from this
|
||||
instance's own interaction records (the aggregate cannot be split by
|
||||
model per agent). Falls back to the unfiltered summary when this
|
||||
instance has no named agents to scope by.
|
||||
"""
|
||||
summary = get_token_collector().get_session_summary()
|
||||
own_names = self._own_agent_names()
|
||||
by_agent = summary.get("by_agent", {})
|
||||
# No named agents to scope by (or nothing tracked yet): fall back to the
|
||||
# unfiltered summary rather than silently reporting zeros.
|
||||
if not own_names or not by_agent:
|
||||
return summary
|
||||
|
||||
scoped_by_agent = {
|
||||
name: metrics for name, metrics in by_agent.items() if name in own_names
|
||||
}
|
||||
totals = {
|
||||
"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0,
|
||||
"reasoning_tokens": 0, "audio_input_tokens": 0, "audio_output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for metrics in scoped_by_agent.values():
|
||||
for key, value in metrics.items():
|
||||
if isinstance(value, (int, float)):
|
||||
totals[key] = totals.get(key, 0) + value
|
||||
|
||||
# Re-derive per-model totals and the interaction count from this
|
||||
# instance's own records so they are not copied wholesale from the
|
||||
# process-global summary (which mixes in other instances' models and
|
||||
# counts). The collector's ``by_agent`` aggregate cannot be split by
|
||||
# model, so the per-interaction window is the correct source.
|
||||
own_interactions = self._scoped_recent_interactions(own_names)
|
||||
by_model: Dict[str, Dict[str, int]] = {}
|
||||
for interaction in own_interactions:
|
||||
model = interaction.get("model")
|
||||
metrics = interaction.get("metrics") or {}
|
||||
if not model:
|
||||
continue
|
||||
bucket = by_model.setdefault(model, {})
|
||||
for key, value in metrics.items():
|
||||
if isinstance(value, (int, float)):
|
||||
bucket[key] = bucket.get(key, 0) + value
|
||||
|
||||
return {
|
||||
"total_interactions": len(own_interactions),
|
||||
"total_tokens": totals.get("total_tokens", 0),
|
||||
"total_metrics": totals,
|
||||
"by_model": by_model,
|
||||
"by_agent": scoped_by_agent,
|
||||
}
|
||||
|
||||
def get_token_usage_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of token usage across this team's agents and tasks."""
|
||||
"""Get a summary of token usage across all agents and tasks."""
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
return self._scoped_token_summary()
|
||||
return get_token_collector().get_session_summary()
|
||||
|
||||
def get_detailed_token_report(self) -> Dict[str, Any]:
|
||||
"""Get a detailed token usage report."""
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
summary = self._scoped_token_summary()
|
||||
own_names = self._own_agent_names()
|
||||
if own_names:
|
||||
# Only surface this instance's own interactions so the detailed
|
||||
# report never exposes another concurrent instance's records.
|
||||
recent = self._scoped_recent_interactions(own_names)[-20:]
|
||||
else:
|
||||
recent = get_token_collector().get_recent_interactions(limit=20)
|
||||
collector = get_token_collector()
|
||||
summary = collector.get_session_summary()
|
||||
recent = collector.get_recent_interactions(limit=20)
|
||||
|
||||
# Calculate cost estimates (example rates)
|
||||
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
|
||||
@@ -2741,7 +2427,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
print("Token tracking not available")
|
||||
return
|
||||
|
||||
summary = self._scoped_token_summary()
|
||||
summary = get_token_collector().get_session_summary()
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("TOKEN USAGE SUMMARY")
|
||||
@@ -2771,7 +2457,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
print("="*50 + "\n")
|
||||
|
||||
def launch(self, path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
|
||||
def launch(self, path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
|
||||
"""
|
||||
Launch all agents as a single API endpoint (HTTP) or an MCP server.
|
||||
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
|
||||
@@ -2782,11 +2468,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
Args:
|
||||
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
|
||||
avoid binding all interfaces by default — pass '0.0.0.0'
|
||||
explicitly to expose externally). Binding a non-loopback host
|
||||
without ``PRAISONAI_LAUNCH_AUTH_TOKEN`` set auto-generates and
|
||||
prints a one-time bearer token.
|
||||
host: Server host (default: '0.0.0.0')
|
||||
debug: Enable debug mode for uvicorn (default: False)
|
||||
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
|
||||
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
|
||||
@@ -2796,7 +2478,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
if protocol == "http":
|
||||
# Use centralized server registry
|
||||
|
||||
|
||||
if not self.agents:
|
||||
logging.warning("No agents to launch for HTTP mode. Add agents to the Agents instance first.")
|
||||
return
|
||||
@@ -2825,13 +2507,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
print("\nOr install all API dependencies with:")
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
# Fail-closed bind guard shared with Agent.launch: never serve
|
||||
# keyless on a non-loopback host. Applied only after the no-agents
|
||||
# and dependency guards above, so a launch that bails out cannot
|
||||
# mutate the process-wide launch token (which every route reads per
|
||||
# request) and retroactively 401 an already-running endpoint.
|
||||
host = _resolve_launch_host(host)
|
||||
|
||||
# Thread-safe initialization of FastAPI app
|
||||
app, is_new = _server_registry.get_or_create_app(
|
||||
@@ -3470,14 +3145,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
|
||||
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
|
||||
|
||||
# Keep plan-step tasks/results under _plan_tasks for introspection,
|
||||
# then restore the user's original task set as the canonical self.tasks.
|
||||
self._plan_tasks = self.tasks
|
||||
self.tasks = original_tasks
|
||||
with self._task_id_lock:
|
||||
self.task_id_counter = (
|
||||
max(original_tasks.keys()) + 1 if original_tasks else 0
|
||||
)
|
||||
# Restore original tasks reference for result retrieval
|
||||
self._plan_tasks = self.tasks.copy()
|
||||
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
|
||||
|
||||
# Resource Lifecycle Management
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -101,17 +101,15 @@ def mark_approved(
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> None:
|
||||
get_approval_registry().mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
get_approval_registry().mark_approved(tool_name, arguments, agent_name)
|
||||
|
||||
def is_already_approved(
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name, scope_id)
|
||||
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name)
|
||||
|
||||
def is_yaml_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_yaml_approved(tool_name)
|
||||
|
||||
@@ -80,44 +80,13 @@ class AutoApproveBackend:
|
||||
class ConsoleBackend:
|
||||
"""Interactive Rich terminal prompt. Default for CLI usage."""
|
||||
|
||||
@staticmethod
|
||||
def _render_diff_markup(diff: str, max_lines: int = 40) -> str:
|
||||
"""Return Rich-markup for a unified *diff* with coloured +/- hunks.
|
||||
|
||||
Escapes any literal ``[`` in the diff so raw file content can never be
|
||||
interpreted as Rich markup. Added lines are green, removed lines red,
|
||||
hunk headers cyan and context lines dimmed. Bounded to ``max_lines``.
|
||||
"""
|
||||
from rich.markup import escape
|
||||
|
||||
lines = diff.splitlines()
|
||||
rendered = []
|
||||
for i, line in enumerate(lines):
|
||||
if i >= max_lines:
|
||||
rendered.append("[dim]... (diff truncated)[/dim]")
|
||||
break
|
||||
safe = escape(line)
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
rendered.append(f"[bold]{safe}[/bold]")
|
||||
elif line.startswith("@@"):
|
||||
rendered.append(f"[cyan]{safe}[/cyan]")
|
||||
elif line.startswith("+"):
|
||||
rendered.append(f"[green]{safe}[/green]")
|
||||
elif line.startswith("-"):
|
||||
rendered.append(f"[red]{safe}[/red]")
|
||||
else:
|
||||
rendered.append(f"[dim]{safe}[/dim]")
|
||||
return "\n".join(rendered) + "\n"
|
||||
|
||||
def _prompt_user(self, request: ApprovalRequest):
|
||||
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
|
||||
"""Show Rich panel and ask once/session/always/no.
|
||||
|
||||
Returns a ``(approved, scope, scope_pattern, feedback)`` tuple where
|
||||
``scope`` is one of ``"once"`` / ``"session"`` / ``"always"`` and
|
||||
``scope_pattern`` is the reusable target to persist for ``always``
|
||||
(``None`` otherwise). ``feedback`` carries optional free-text guidance
|
||||
captured by the "deny & redirect" choice (``None`` otherwise).
|
||||
A plain denial returns ``(False, "once", None, None)``.
|
||||
Returns a ``(approved, scope, scope_pattern)`` tuple where ``scope`` is
|
||||
one of ``"once"`` / ``"session"`` / ``"always"`` and ``scope_pattern``
|
||||
is the reusable target to persist for ``always`` (``None`` otherwise).
|
||||
A denial returns ``(False, "once", None)``.
|
||||
"""
|
||||
Console = _get_rich_console()
|
||||
Panel = _get_rich_panel()
|
||||
@@ -137,22 +106,12 @@ class ConsoleBackend:
|
||||
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{request.risk_level.upper()}[/{risk_color}]\n"
|
||||
if request.agent_name:
|
||||
tool_info += f"[bold]Agent:[/] {request.agent_name}\n"
|
||||
|
||||
# For file-mutating tools a rendered unified diff is attached to the
|
||||
# request context so the reviewer sees the actual change (path plus
|
||||
# +/- hunks) instead of a truncated argument dump. Fall back to the
|
||||
# argument summary when no diff is present (non-edit tools).
|
||||
diff = (request.context or {}).get("diff")
|
||||
if diff:
|
||||
tool_info += "[bold]Diff:[/]\n"
|
||||
tool_info += self._render_diff_markup(diff)
|
||||
else:
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in request.arguments.items():
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in request.arguments.items():
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
console.print(Panel(
|
||||
tool_info.strip(),
|
||||
@@ -167,35 +126,28 @@ class ConsoleBackend:
|
||||
try:
|
||||
console.print(
|
||||
f"[{risk_color}]Allow {request.tool_name}?[/{risk_color}] "
|
||||
f"[o] once [s] this session [a] {always_label} "
|
||||
f"[n] no [d] deny & redirect"
|
||||
f"[o] once [s] this session [a] {always_label} [n] no"
|
||||
)
|
||||
choice = Prompt.ask(
|
||||
"Choice",
|
||||
choices=["o", "s", "a", "n", "d"],
|
||||
choices=["o", "s", "a", "n"],
|
||||
default="n",
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return (False, "once", None, None)
|
||||
return (False, "once", None)
|
||||
|
||||
if choice == "n":
|
||||
return (False, "once", None, None)
|
||||
if choice == "d":
|
||||
try:
|
||||
feedback = Prompt.ask("What should the agent do instead?").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
feedback = ""
|
||||
return (False, "once", None, feedback or None)
|
||||
return (False, "once", None)
|
||||
if choice == "s":
|
||||
return (True, "session", None, None)
|
||||
return (True, "session", None)
|
||||
if choice == "a":
|
||||
return (True, "always", suggested or None, None)
|
||||
return (True, "once", None, None)
|
||||
return (True, "always", suggested or None)
|
||||
return (True, "once", None)
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous approval via Rich console prompt."""
|
||||
try:
|
||||
approved, scope, scope_pattern, feedback = self._prompt_user(request)
|
||||
approved, scope, scope_pattern = self._prompt_user(request)
|
||||
if approved:
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
@@ -204,13 +156,7 @@ class ConsoleBackend:
|
||||
scope=scope,
|
||||
scope_pattern=scope_pattern,
|
||||
)
|
||||
reason = f"User denied: {feedback}" if feedback else "User denied"
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=reason,
|
||||
approver="console",
|
||||
feedback=feedback,
|
||||
)
|
||||
return ApprovalDecision(approved=False, reason="User denied", approver="console")
|
||||
except Exception as e:
|
||||
logger.error("Console approval error: %s", e)
|
||||
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
|
||||
|
||||
@@ -32,12 +32,6 @@ class ApprovalRequest:
|
||||
approval_id: Stable correlation id that survives a process restart
|
||||
and can be matched to an inbound channel callback.
|
||||
Auto-generated when not supplied.
|
||||
authorized_reviewers: Optional set of reviewer identities allowed to
|
||||
resolve this request. ``None`` (default) keeps today's
|
||||
behaviour (any scope-holder may resolve). When populated a
|
||||
backend may bind resolution to these reviewers so that in a
|
||||
multi-operator deployment a request addressed to one
|
||||
operator cannot be resolved by another.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -47,7 +41,6 @@ class ApprovalRequest:
|
||||
session_id: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
approval_id: str = field(default_factory=_new_approval_id)
|
||||
authorized_reviewers: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -72,12 +65,6 @@ class ApprovalDecision:
|
||||
``session``/``always`` scopes (e.g. ``"bash:git status *"``
|
||||
or ``"edit:src/app.py"``). When ``None`` the registry
|
||||
derives one from the tool call.
|
||||
feedback: Optional free-text guidance supplied by the user when
|
||||
denying a tool call ("deny with guidance"). When present
|
||||
on a denial it is surfaced back to the model as a
|
||||
course-correction for the next turn instead of a bare
|
||||
"denied" result. ``None`` (default) keeps today's
|
||||
terminal-deny semantics.
|
||||
"""
|
||||
|
||||
approved: bool
|
||||
@@ -87,7 +74,6 @@ class ApprovalDecision:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
scope: str = "once"
|
||||
scope_pattern: Optional[str] = None
|
||||
feedback: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -245,34 +245,23 @@ class ApprovalRegistry:
|
||||
|
||||
@staticmethod
|
||||
def _approval_cache_key(
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
agent_name: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
tool_name: str, arguments: Dict, agent_name: Optional[str] = None
|
||||
) -> str:
|
||||
# Scope the key to the requesting agent so one agent's approval never
|
||||
# silently pre-authorizes an identical call from a different, stricter
|
||||
# agent in the same context. Prefer the per-instance ``scope_id`` (e.g.
|
||||
# ``Agent._approval_scope_id``) when supplied: the display ``agent_name``
|
||||
# defaults to ``"Agent"`` for every unnamed agent, so keying the
|
||||
# context cache by name alone lets two same-named instances sharing an
|
||||
# execution context pre-authorize each other before the per-instance
|
||||
# session lookup runs. Falls back to ``agent_name`` for backward compat.
|
||||
# ``*`` is the sentinel for calls made outside any Agent (e.g. bare
|
||||
# module-level tool calls).
|
||||
# agent in the same context. ``*`` is the sentinel for calls made
|
||||
# outside any Agent (e.g. bare module-level tool calls).
|
||||
from .utils import hash_tool_args
|
||||
scope = scope_id or agent_name or '*'
|
||||
return f"{scope}:{tool_name}:{hash_tool_args(arguments)}"
|
||||
return f"{agent_name or '*'}:{tool_name}:{hash_tool_args(arguments)}"
|
||||
|
||||
def mark_approved(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> None:
|
||||
approved = self._approved_context.get(set())
|
||||
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id))
|
||||
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name))
|
||||
self._approved_context.set(approved)
|
||||
|
||||
def is_already_approved(
|
||||
@@ -280,32 +269,21 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
# Honour an explicit mark_approved() from the agent approval path even
|
||||
# for critical tools (e.g. execute_command after AutoApproveBackend).
|
||||
if self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id) in self._approved_context.get(set()):
|
||||
if self._approval_cache_key(tool_name, arguments or {}, agent_name) in self._approved_context.get(set()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_session_scoped(
|
||||
self,
|
||||
agent_name: Optional[str],
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict],
|
||||
scope_id: Optional[str] = None,
|
||||
self, agent_name: Optional[str], tool_name: str, arguments: Optional[Dict]
|
||||
) -> bool:
|
||||
"""Return True if a "this session" grant covers this call for the run.
|
||||
|
||||
Checks the in-memory session store by the reusable permission target so
|
||||
a single ``session`` approval covers matching calls (e.g. the same
|
||||
``bash:git status *`` prefix) without persisting anything to disk.
|
||||
|
||||
``scope_id`` is a per-Agent-instance key (e.g. ``Agent._approval_scope_id``).
|
||||
When provided it is used as the store key instead of the display name so
|
||||
two same-named agents (every unnamed ``Agent()`` defaults to ``"Agent"``)
|
||||
can't inherit each other's human-granted session approval. Falls back to
|
||||
``agent_name`` for backward compatibility when no id is supplied.
|
||||
"""
|
||||
if not self._session_scoped_targets:
|
||||
return False
|
||||
@@ -315,8 +293,7 @@ class ApprovalRegistry:
|
||||
target = build_permission_target(tool_name, arguments)
|
||||
except Exception: # noqa: BLE001 — never block on target derivation
|
||||
return False
|
||||
key = scope_id if scope_id is not None else agent_name
|
||||
return (key, target) in self._session_scoped_targets
|
||||
return (agent_name, target) in self._session_scoped_targets
|
||||
|
||||
def _persist_scoped_decision(
|
||||
self,
|
||||
@@ -324,7 +301,6 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict],
|
||||
decision: ApprovalDecision,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a ``session``/``always`` decision for reuse this run (or beyond).
|
||||
|
||||
@@ -350,17 +326,12 @@ class ApprovalRegistry:
|
||||
if not decision.approved:
|
||||
return
|
||||
|
||||
# In-memory "this session" grants are keyed per Agent instance
|
||||
# (scope_id) when available, so a session approval granted on one worker
|
||||
# cannot silently unlock the same tool for a distinct same-named worker.
|
||||
session_key = scope_id if scope_id is not None else agent_name
|
||||
|
||||
if scope == "session":
|
||||
try:
|
||||
from .utils import build_permission_target
|
||||
|
||||
target = build_permission_target(tool_name, arguments)
|
||||
self._session_scoped_targets.add((session_key, target))
|
||||
self._session_scoped_targets.add((agent_name, target))
|
||||
except Exception as e: # noqa: BLE001 — best-effort, in-memory only
|
||||
logger.debug(
|
||||
"Could not record session approval for tool '%s': %s",
|
||||
@@ -380,7 +351,7 @@ class ApprovalRegistry:
|
||||
from .utils import build_permission_target
|
||||
|
||||
self._session_scoped_targets.add(
|
||||
(session_key, build_permission_target(tool_name, arguments))
|
||||
(agent_name, build_permission_target(tool_name, arguments))
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -451,8 +422,6 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
auto_approve_scope: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> ApprovalDecision:
|
||||
"""Synchronous approval — used by ``Agent._execute_tool_impl``.
|
||||
|
||||
@@ -460,46 +429,33 @@ class ApprovalRegistry:
|
||||
registered as requiring approval (e.g. a per-agent ``PermissionManager``
|
||||
``ask`` rule). It applies only to this call and never mutates shared
|
||||
registry state, so it cannot leak an approval gate onto other agents.
|
||||
|
||||
``auto_approve_scope`` is the unique per-instance key used *only* for
|
||||
the skill auto-approval check. It defaults to ``agent_name`` so callers
|
||||
that don't pass it keep the old behaviour; passing a per-instance id
|
||||
(e.g. ``Agent._approval_scope_id``) prevents skill grants from leaking
|
||||
across unrelated agents that share the same display name.
|
||||
|
||||
``scope_id`` is the same per-instance key applied to the in-memory
|
||||
"this session" grant store, so a human "[s] this session" approval on
|
||||
one agent can't silently unlock the tool for a distinct same-named
|
||||
agent. ``agent_name`` is still used for name-keyed lookups (per-agent
|
||||
backend, risk level, ``ask`` rules, durable ``always`` persistence).
|
||||
"""
|
||||
auto_scope = auto_approve_scope or agent_name
|
||||
# Fast-path: not required (checks both global and this agent's scope)
|
||||
if not force and not self.is_required(tool_name, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
# Already approved in this context
|
||||
if self.is_already_approved(tool_name, arguments, agent_name, scope_id):
|
||||
if self.is_already_approved(tool_name, arguments, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# "This session" scoped grant covers matching calls for the run
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments, scope_id):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, auto_scope):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
# Env auto-approve
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
# YAML auto-approve
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
# Delegate to backend
|
||||
@@ -523,8 +479,8 @@ class ApprovalRegistry:
|
||||
)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
|
||||
return decision
|
||||
|
||||
async def approve_async(
|
||||
@@ -533,39 +489,35 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
auto_approve_scope: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> ApprovalDecision:
|
||||
"""Asynchronous approval — used by async tool execution path.
|
||||
|
||||
See :meth:`approve_sync` for the ``force``, ``auto_approve_scope`` and
|
||||
``scope_id`` semantics (per-call gate, no shared-state mutation,
|
||||
per-instance session-grant keying).
|
||||
See :meth:`approve_sync` for the ``force`` semantics (per-call gate,
|
||||
no shared-state mutation).
|
||||
"""
|
||||
auto_scope = auto_approve_scope or agent_name
|
||||
# Fast-path: not required (checks both global and this agent's scope)
|
||||
if not force and not self.is_required(tool_name, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
if self.is_already_approved(tool_name, arguments, agent_name, scope_id):
|
||||
if self.is_already_approved(tool_name, arguments, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# "This session" scoped grant covers matching calls for the run
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments, scope_id):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, auto_scope):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
backend = self.get_backend(agent_name)
|
||||
@@ -585,6 +537,6 @@ class ApprovalRegistry:
|
||||
decision = ApprovalDecision(approved=False, reason="Approval timed out")
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name, arguments, agent_name, scope_id)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
|
||||
return decision
|
||||
@@ -114,108 +114,6 @@ def build_permission_target(
|
||||
return f"tool:{tool_name}"
|
||||
|
||||
|
||||
def build_diff_preview(
|
||||
tool_name: str, arguments: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[str]:
|
||||
"""Build a readable unified diff for a file-mutating tool call.
|
||||
|
||||
Lets an approval prompt show the *actual* pending change (path plus
|
||||
``+``/``-`` hunks) instead of a truncated argument dump. Uses only the
|
||||
stdlib :mod:`difflib`; no new dependencies. Returns ``None`` for tools that
|
||||
are not file mutations (or when the diff cannot be computed) so callers can
|
||||
fall back to the existing argument summary.
|
||||
|
||||
Supported tools:
|
||||
|
||||
* ``edit_file`` — targeted ``old_string`` -> ``new_string`` replacement at
|
||||
``filepath``/``path``. Honours ``replace_all`` (default ``False`` =
|
||||
first occurrence only) so the preview matches what ``edit_file`` applies.
|
||||
* ``acp_edit_file`` — whole-file replace: ``new_content`` at ``filepath``
|
||||
diffed against the current on-disk file.
|
||||
* ``write_file`` / ``acp_create_file`` — new ``content`` against the
|
||||
current on-disk file (empty when the file does not yet exist).
|
||||
* ``apply_patch`` — the ``patch`` text is already a unified diff, returned
|
||||
verbatim.
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
|
||||
args = arguments or {}
|
||||
|
||||
def _path() -> str:
|
||||
for key in _PATH_KEYS:
|
||||
value = args.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return tool_name
|
||||
|
||||
def _read_existing(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
def _unified(old: str, new: str, path: str) -> Optional[str]:
|
||||
diff = "".join(
|
||||
difflib.unified_diff(
|
||||
old.splitlines(keepends=True),
|
||||
new.splitlines(keepends=True),
|
||||
fromfile=f"{path} (before)",
|
||||
tofile=f"{path} (after)",
|
||||
n=3,
|
||||
)
|
||||
)
|
||||
return diff or None
|
||||
|
||||
try:
|
||||
if tool_name == "edit_file":
|
||||
old_string = args.get("old_string")
|
||||
new_string = args.get("new_string")
|
||||
if not isinstance(old_string, str) or not isinstance(new_string, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path)
|
||||
if existing and old_string and old_string in existing:
|
||||
# Mirror ``edit_file`` semantics: ``replace_all`` defaults to
|
||||
# False (first occurrence only), so the preview must not show
|
||||
# every occurrence changing when only the first will be edited.
|
||||
if args.get("replace_all"):
|
||||
new_content = existing.replace(old_string, new_string)
|
||||
else:
|
||||
new_content = existing.replace(old_string, new_string, 1)
|
||||
return _unified(existing, new_content, path)
|
||||
return _unified(old_string, new_string, path)
|
||||
|
||||
if tool_name == "acp_edit_file":
|
||||
# ACP edits are whole-file replacements: ``new_content`` at
|
||||
# ``filepath`` (no old_string/new_string contract).
|
||||
new_content = args.get("new_content")
|
||||
if not isinstance(new_content, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path) if os.path.exists(path) else ""
|
||||
return _unified(existing, new_content, path)
|
||||
|
||||
if tool_name in ("write_file", "acp_create_file"):
|
||||
content = args.get("content")
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path) if os.path.exists(path) else ""
|
||||
return _unified(existing, content, path)
|
||||
|
||||
if tool_name == "apply_patch":
|
||||
patch = args.get("patch")
|
||||
if isinstance(patch, str) and patch.strip():
|
||||
return patch
|
||||
return None
|
||||
except Exception: # noqa: BLE001 — preview is advisory; never break approval
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_coroutine_safely(
|
||||
coro: Awaitable[T],
|
||||
timeout: Optional[float] = None
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
Authentication & Authorization Protocols for PraisonAI Agents.
|
||||
|
||||
This package provides protocol-driven auth interfaces for the core SDK.
|
||||
Implementations (JWT, OAuth, etc.) live outside it — implement the protocols
|
||||
against your own store, or use the reference implementation in the separate
|
||||
``praisonai-platform`` package
|
||||
(https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
Implementations (JWT, OAuth, etc.) live in the platform package.
|
||||
|
||||
Quick start::
|
||||
|
||||
|
||||
@@ -155,16 +155,11 @@ class WorkspaceContextProtocol(Protocol):
|
||||
"""Protocol for providing workspace context to agents.
|
||||
|
||||
Enables agents to access workspace-specific settings, instructions,
|
||||
and agent configurations from a platform layer.
|
||||
|
||||
Implementations live outside this package. The reference one is
|
||||
``PlatformWorkspaceContext`` in the ``praisonai-platform`` package
|
||||
(https://github.com/MervinPraison/PraisonAI-Platform); implement this
|
||||
protocol yourself to back agent configuration with your own store.
|
||||
and agent configurations from the platform layer.
|
||||
|
||||
Example::
|
||||
|
||||
class MyWorkspaceContext:
|
||||
class PlatformWorkspaceContext:
|
||||
async def get_workspace_context(self, workspace_id):
|
||||
ws = await db.get_workspace(workspace_id)
|
||||
return ws.context
|
||||
|
||||
@@ -89,11 +89,6 @@ class BotConfig:
|
||||
# replaces it with done_emoji on completion (e.g. "✅").
|
||||
ack_emoji: str = ""
|
||||
done_emoji: str = "✅"
|
||||
# Which inbound messages get an ack reaction. One of:
|
||||
# "off" | "direct" | "group-mentions" | "group-all" | "all".
|
||||
# Default "group-mentions": ack DMs and group mentions/replies, but stay
|
||||
# quiet on ambient group chatter the bot won't answer.
|
||||
ack_scope: str = "group-mentions"
|
||||
|
||||
# Session TTL in seconds. 0 = disabled (sessions never expire).
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
@@ -163,7 +158,6 @@ class BotConfig:
|
||||
"debounce_ms": self.debounce_ms,
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"ack_scope": self.ack_scope,
|
||||
"session_ttl": self.session_ttl,
|
||||
"busy_mode": self.busy_mode,
|
||||
"busy_ack": self.busy_ack,
|
||||
|
||||
@@ -41,8 +41,6 @@ __all__ = [
|
||||
"Subscriber",
|
||||
"get_default_bus",
|
||||
"set_default_bus",
|
||||
"EventLogProtocol",
|
||||
"SqliteEventLog",
|
||||
]
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
@@ -52,8 +50,6 @@ _LAZY_IMPORTS = {
|
||||
"Subscriber": ("praisonaiagents.bus.bus", "Subscriber"),
|
||||
"get_default_bus": ("praisonaiagents.bus.bus", "get_default_bus"),
|
||||
"set_default_bus": ("praisonaiagents.bus.bus", "set_default_bus"),
|
||||
"EventLogProtocol": ("praisonaiagents.bus.event_log", "EventLogProtocol"),
|
||||
"SqliteEventLog": ("praisonaiagents.bus.event_log", "SqliteEventLog"),
|
||||
}
|
||||
|
||||
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
|
||||
@@ -63,9 +63,6 @@ class EventBus:
|
||||
self._lock = threading.RLock()
|
||||
self._event_history: List[Event] = []
|
||||
self._max_history = 1000
|
||||
# Durable sinks (e.g. SqliteEventLog). Opt-in: a bare Agent() attaches
|
||||
# none, preserving the zero-overhead in-memory path.
|
||||
self._sinks: List[Any] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
@@ -172,9 +169,8 @@ class EventBus:
|
||||
Returns:
|
||||
The published Event object
|
||||
"""
|
||||
# Fast path: if no subscribers and no durable sinks, return a minimal
|
||||
# event without expensive operations.
|
||||
if not self._subscribers and not self._sinks:
|
||||
# Fast path: if no subscribers, return a minimal event without expensive operations
|
||||
if not self._subscribers:
|
||||
# Convert EventType enum to string
|
||||
type_str = event_type.value if isinstance(event_type, EventType) else event_type
|
||||
return Event(
|
||||
@@ -208,10 +204,6 @@ class EventBus:
|
||||
"""
|
||||
logger.debug(f"Publishing event: {event.type}")
|
||||
|
||||
# Durable persistence first (best-effort): a durable sink must receive
|
||||
# the event even when there are no in-memory subscribers.
|
||||
self._dispatch_to_sinks(event)
|
||||
|
||||
# Fast path: if no subscribers, skip expensive work
|
||||
if not self._subscribers:
|
||||
return event
|
||||
@@ -267,9 +259,8 @@ class EventBus:
|
||||
"""
|
||||
type_str = event_type.value if isinstance(event_type, EventType) else event_type
|
||||
|
||||
# Fast path: if no subscribers and no durable sinks, return a minimal
|
||||
# event without expensive operations.
|
||||
if not self._subscribers and not self._sinks:
|
||||
# Fast path: if no subscribers, return a minimal event without expensive operations
|
||||
if not self._subscribers:
|
||||
return Event(
|
||||
type=type_str,
|
||||
data=data or {},
|
||||
@@ -286,13 +277,6 @@ class EventBus:
|
||||
|
||||
logger.debug(f"Publishing async event: {event.type}")
|
||||
|
||||
# Durable persistence first (best-effort).
|
||||
self._dispatch_to_sinks(event)
|
||||
|
||||
# Fast path: if no subscribers, skip expensive work
|
||||
if not self._subscribers:
|
||||
return event
|
||||
|
||||
# Store in history
|
||||
with self._lock:
|
||||
self._event_history.append(event)
|
||||
@@ -354,54 +338,6 @@ class EventBus:
|
||||
with self._lock:
|
||||
self._subscribers.clear()
|
||||
|
||||
def attach_sink(self, sink: Any) -> None:
|
||||
"""Attach a durable sink that persists every published event.
|
||||
|
||||
A sink is any object with an ``append(event)`` method (e.g.
|
||||
:class:`~praisonaiagents.bus.event_log.SqliteEventLog`). Sinks receive
|
||||
every event regardless of subscriber filters, and their failures are
|
||||
swallowed so a full/slow log never breaks the originating turn.
|
||||
|
||||
Args:
|
||||
sink: Object exposing ``append(event: Event) -> None``.
|
||||
"""
|
||||
with self._lock:
|
||||
if sink not in self._sinks:
|
||||
self._sinks.append(sink)
|
||||
|
||||
def detach_sink(self, sink: Any) -> bool:
|
||||
"""Detach a previously attached sink.
|
||||
|
||||
Args:
|
||||
sink: The sink object to remove.
|
||||
|
||||
Returns:
|
||||
True if the sink was attached and removed, False otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
self._sinks.remove(sink)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_sinks(self) -> bool:
|
||||
"""Check if there are any durable sinks (lock-free for performance)."""
|
||||
return bool(self._sinks)
|
||||
|
||||
def _dispatch_to_sinks(self, event: Event) -> None:
|
||||
"""Best-effort durable persistence: never raises into the caller."""
|
||||
if not self._sinks:
|
||||
return
|
||||
with self._lock:
|
||||
sinks = list(self._sinks)
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.append(event)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug(f"Error in event sink: {e}")
|
||||
|
||||
@property
|
||||
def subscriber_count(self) -> int:
|
||||
"""Get the number of subscribers."""
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
"""
|
||||
Durable, queryable per-session lifecycle event log.
|
||||
|
||||
The :class:`~praisonaiagents.bus.bus.EventBus` keeps only a bounded in-memory
|
||||
ring buffer, so its history is lost on restart and cannot be read back per
|
||||
session. This module adds a durable, append-only *sink* that persists the very
|
||||
same :class:`~praisonaiagents.bus.event.Event` shapes to SQLite, keyed by
|
||||
``session_id`` with a monotonic per-session ``seq``, so operators and dashboards
|
||||
can read one ordered timeline that survives a restart:
|
||||
|
||||
events(session_id, after_seq) -> ordered list[Event]
|
||||
|
||||
Design notes (mirrors the durable ``runs/sqlite_ledger.py`` posture):
|
||||
|
||||
- **Opt-in.** A bare ``Agent()`` attaches no sink, so the zero-overhead
|
||||
in-memory path is untouched. The gateway wires a sink in explicitly.
|
||||
- **Best-effort.** :meth:`SqliteEventLog.append` never raises into the caller;
|
||||
a full/slow/broken log must never break the turn that produced the event.
|
||||
- **Bounded.** :meth:`SqliteEventLog.prune` trims by age and row count.
|
||||
- **Reuse, don't reinvent.** Persists the existing ``Event``/``EventType``
|
||||
shapes; adds no new status vocabulary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .._logging import get_logger
|
||||
from .event import Event, EventType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["EventLogProtocol", "SqliteEventLog"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EventLogProtocol(Protocol):
|
||||
"""Durable sink for lifecycle events, keyed by session with a cursor.
|
||||
|
||||
Implementations persist events published to an :class:`EventBus` so the
|
||||
lifecycle timeline of a session survives a restart and can be read back in
|
||||
order with a cursor. ``append`` must be best-effort (never raise into the
|
||||
caller).
|
||||
"""
|
||||
|
||||
def append(self, event: Event) -> None:
|
||||
"""Durably record ``event``. Best-effort: never raises into caller."""
|
||||
...
|
||||
|
||||
def query(
|
||||
self, session_id: str, after_seq: int = 0, limit: int = 500
|
||||
) -> List[Event]:
|
||||
"""Return events for ``session_id`` with ``seq > after_seq``, ordered."""
|
||||
...
|
||||
|
||||
def prune(self, *, older_than_days: float, max_rows: int) -> int:
|
||||
"""Delete old/excess rows. Returns the number of rows removed."""
|
||||
...
|
||||
|
||||
|
||||
def _session_id_of(event: Event) -> str:
|
||||
"""Best-effort resolution of the session an event belongs to.
|
||||
|
||||
Prefers an explicit ``session_id`` in ``data``/``metadata`` and falls back
|
||||
to ``event.source`` (many publish sites pass the session id there).
|
||||
"""
|
||||
for container in (event.data, event.metadata):
|
||||
if isinstance(container, dict):
|
||||
sid = container.get("session_id")
|
||||
if sid:
|
||||
return str(sid)
|
||||
if event.source:
|
||||
return str(event.source)
|
||||
return ""
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
session_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
ts REAL NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
event_id TEXT,
|
||||
source TEXT,
|
||||
data_json TEXT,
|
||||
metadata_json TEXT,
|
||||
PRIMARY KEY (session_id, seq)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
||||
CREATE TABLE IF NOT EXISTS event_seq (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
last_seq INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_MIGRATIONS = (
|
||||
"ALTER TABLE events ADD COLUMN metadata_json TEXT",
|
||||
)
|
||||
|
||||
# Seed the per-session high-water-mark from any pre-existing event rows so a
|
||||
# database created before ``event_seq`` existed continues numbering above its
|
||||
# current MAX(seq) instead of colliding on the primary key.
|
||||
_SEED_HWM = (
|
||||
"INSERT INTO event_seq (session_id, last_seq) "
|
||||
"SELECT session_id, MAX(seq) FROM events "
|
||||
"WHERE session_id NOT IN (SELECT session_id FROM event_seq) "
|
||||
"GROUP BY session_id"
|
||||
)
|
||||
|
||||
|
||||
class SqliteEventLog(EventLogProtocol):
|
||||
"""Durable, restart-safe lifecycle event log backed by SQLite.
|
||||
|
||||
A durable sibling of :class:`~praisonaiagents.runs.sqlite_ledger.SQLiteRunLedger`.
|
||||
One row per event ``(session_id, seq, ts, type, event_id, source, data_json,
|
||||
metadata_json)``, append-only, WAL, bounded. Per-session ``seq`` is allocated
|
||||
from a persistent ``event_seq`` high-water-mark table so cursors stay
|
||||
monotonic across restarts and full prunes. Attach it to a bus with
|
||||
``bus.attach_sink(log)``.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite file. Defaults to
|
||||
``<runs_dir>/events.sqlite``. Use ``":memory:"`` for tests.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||
if db_path is None:
|
||||
from ..paths import get_runs_dir
|
||||
|
||||
runs_dir = get_runs_dir()
|
||||
os.makedirs(runs_dir, exist_ok=True)
|
||||
db_path = str(runs_dir / "events.sqlite")
|
||||
elif db_path != ":memory:":
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
self._db_path = db_path
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(
|
||||
db_path, check_same_thread=False, isolation_level=None
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._conn.execute("PRAGMA busy_timeout = 5000")
|
||||
if db_path != ":memory:":
|
||||
try:
|
||||
self._conn.execute("PRAGMA journal_mode = WAL")
|
||||
self._conn.execute("PRAGMA synchronous = NORMAL")
|
||||
except sqlite3.OperationalError: # pragma: no cover - defensive
|
||||
pass
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._migrate()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
"""Best-effort additive migrations for pre-existing databases."""
|
||||
for stmt in _MIGRATIONS:
|
||||
try:
|
||||
self._conn.execute(stmt)
|
||||
except sqlite3.OperationalError:
|
||||
# Column already exists (fresh schema) — nothing to do.
|
||||
pass
|
||||
try:
|
||||
self._conn.execute(_SEED_HWM)
|
||||
except sqlite3.OperationalError: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
# ── public API (EventLogProtocol) ─────────────────────────────────
|
||||
|
||||
def append(self, event: Event) -> None:
|
||||
"""Durably record ``event``. Best-effort: never raises into caller.
|
||||
|
||||
A monotonic per-session ``seq`` is assigned from a persistent
|
||||
high-water-mark table (``event_seq``) inside a serialising
|
||||
``BEGIN IMMEDIATE`` transaction. This makes allocation atomic even when
|
||||
two :class:`SqliteEventLog` instances share one database file, and keeps
|
||||
the cursor monotonic across restarts *and* after a full prune (deleting
|
||||
every row for a session never rewinds its counter).
|
||||
"""
|
||||
try:
|
||||
session_id = _session_id_of(event)
|
||||
try:
|
||||
data_json = json.dumps(event.data)
|
||||
except (TypeError, ValueError):
|
||||
data_json = json.dumps(event.data, default=str)
|
||||
metadata_json = self._dumps_or_none(event.metadata)
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
row = self._conn.execute(
|
||||
"SELECT last_seq FROM event_seq WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
next_seq = (int(row["last_seq"]) if row else 0) + 1
|
||||
self._conn.execute(
|
||||
"INSERT INTO event_seq (session_id, last_seq) VALUES (?, ?) "
|
||||
"ON CONFLICT(session_id) DO UPDATE SET last_seq = excluded.last_seq",
|
||||
(session_id, next_seq),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO events "
|
||||
"(session_id, seq, ts, type, event_id, source, "
|
||||
"data_json, metadata_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
next_seq,
|
||||
float(event.timestamp),
|
||||
event.type,
|
||||
event.id,
|
||||
event.source,
|
||||
data_json,
|
||||
metadata_json,
|
||||
),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
self._conn.execute("ROLLBACK")
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
raise
|
||||
except Exception as exc: # best-effort: never break the originating turn
|
||||
logger.debug("SqliteEventLog.append skipped: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _dumps_or_none(value: Any) -> Optional[str]:
|
||||
"""Serialise ``value`` to JSON, coercing non-native types; ``None`` if empty."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(value, default=str)
|
||||
|
||||
def query(
|
||||
self, session_id: str, after_seq: int = 0, limit: int = 500
|
||||
) -> List[Event]:
|
||||
"""Return events for ``session_id`` with ``seq > after_seq``, in order."""
|
||||
try:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM events WHERE session_id = ? AND seq > ? "
|
||||
"ORDER BY seq ASC LIMIT ?",
|
||||
(session_id, int(after_seq), int(limit)),
|
||||
).fetchall()
|
||||
return [self._row_to_event(r) for r in rows]
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("SqliteEventLog.query failed: %s", exc)
|
||||
return []
|
||||
|
||||
def prune(self, *, older_than_days: float, max_rows: int) -> int:
|
||||
"""Delete rows older than ``older_than_days`` and beyond ``max_rows``.
|
||||
|
||||
Returns the number of rows deleted. Best-effort; returns ``0`` on error.
|
||||
"""
|
||||
removed = 0
|
||||
try:
|
||||
with self._lock:
|
||||
if older_than_days and older_than_days > 0:
|
||||
cutoff = time.time() - older_than_days * 86400.0
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM events WHERE ts < ?", (cutoff,)
|
||||
)
|
||||
removed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
||||
if max_rows and max_rows > 0:
|
||||
total = self._conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM events"
|
||||
).fetchone()["c"]
|
||||
excess = int(total) - int(max_rows)
|
||||
if excess > 0:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM events WHERE rowid IN ("
|
||||
"SELECT rowid FROM events ORDER BY ts ASC LIMIT ?)",
|
||||
(excess,),
|
||||
)
|
||||
removed += (
|
||||
cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("SqliteEventLog.prune failed: %s", exc)
|
||||
return removed
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying database connection."""
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _row_to_event(row: sqlite3.Row) -> Event:
|
||||
try:
|
||||
data = json.loads(row["data_json"]) if row["data_json"] else {}
|
||||
except (ValueError, TypeError):
|
||||
data = {}
|
||||
# Restore caller-provided metadata, then overlay the durable cursor
|
||||
# fields (seq, session_id) so correlation/diagnostic keys survive the
|
||||
# round trip.
|
||||
metadata: dict = {}
|
||||
raw_meta = row["metadata_json"] if "metadata_json" in row.keys() else None
|
||||
if raw_meta:
|
||||
try:
|
||||
loaded = json.loads(raw_meta)
|
||||
if isinstance(loaded, dict):
|
||||
metadata.update(loaded)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
metadata["seq"] = row["seq"]
|
||||
metadata["session_id"] = row["session_id"]
|
||||
return Event(
|
||||
id=row["event_id"] or "",
|
||||
type=row["type"] or EventType.CUSTOM.value,
|
||||
data=data,
|
||||
timestamp=row["ts"],
|
||||
source=row["source"],
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -37,8 +37,6 @@ __all__ = [
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
# Default structured summary builder
|
||||
"DefaultSummaryBuilder",
|
||||
# Read-only recap
|
||||
"build_recap",
|
||||
"MemoryFlushResult",
|
||||
@@ -77,10 +75,6 @@ def __getattr__(name: str):
|
||||
from .protocols import SummaryBuilderProtocol
|
||||
return SummaryBuilderProtocol
|
||||
|
||||
if name == "DefaultSummaryBuilder":
|
||||
from .summary_builder import DefaultSummaryBuilder
|
||||
return DefaultSummaryBuilder
|
||||
|
||||
if name == "build_recap":
|
||||
from .recap import build_recap
|
||||
return build_recap
|
||||
|
||||
@@ -122,18 +122,7 @@ class ContextCompactor:
|
||||
|
||||
# Protocol implementations (defaults to None - no heavy implementations in core)
|
||||
self.tool_pruner = tool_pruner
|
||||
self.message_formatter = message_formatter
|
||||
# Default the structured summary builder when the config asks for the
|
||||
# phase-aware template but no builder was injected, so
|
||||
# `_build_structured_summary` renders SUMMARY_TEMPLATE instead of the
|
||||
# "Summary of N messages" stub. The builder is deterministic and
|
||||
# dependency-free, so this stays offline-safe and zero-overhead.
|
||||
if summary_builder is None and getattr(self.config, "structured_template", False):
|
||||
from .summary_builder import DefaultSummaryBuilder
|
||||
|
||||
summary_builder = DefaultSummaryBuilder(
|
||||
llm_summarize_fn=self.llm_summarize_fn
|
||||
)
|
||||
self.message_formatter = message_formatter
|
||||
self.summary_builder = summary_builder
|
||||
|
||||
# Anti-thrashing state tracking
|
||||
|
||||
@@ -263,7 +263,7 @@ def create_memory_flush_agent(
|
||||
) -> Any:
|
||||
"""Create a restricted child agent sharing only the parent's memory store."""
|
||||
from ..agent.agent import Agent
|
||||
from ..config.feature_configs import ExecutionConfig, OutputConfig
|
||||
from ..config.feature_configs import ExecutionConfig
|
||||
from ..tools.memory import search_memory, store_memory
|
||||
|
||||
llm = config.llm or getattr(parent_agent, "llm", None)
|
||||
@@ -286,7 +286,8 @@ def create_memory_flush_agent(
|
||||
else getattr(parent_agent, "_memory_instance", None)
|
||||
),
|
||||
execution=ExecutionConfig(context_compaction=False, max_steps=3),
|
||||
output=OutputConfig(verbose=False, stream=False),
|
||||
verbose=False,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Default structured summary builder for context compaction.
|
||||
|
||||
This is the concrete :class:`~praisonaiagents.compaction.protocols.
|
||||
SummaryBuilderProtocol` implementation that renders the declared
|
||||
:data:`~praisonaiagents.compaction.config.SUMMARY_TEMPLATE` — the phase-aware
|
||||
sections (``## Active Task`` / ``## Completed Actions`` / ``## In Progress`` /
|
||||
``## Pending Questions`` / ``## Relevant Files / Paths`` / ``## Remaining
|
||||
Work``) — so a compacted context keeps its structure instead of degrading to a
|
||||
one-line ``"Summary of N messages"`` count.
|
||||
|
||||
Design: **deterministic, dependency-free, offline-safe.** It categorises the
|
||||
transcript into the template sections from the messages themselves (roles,
|
||||
tool calls, question marks, file-path tokens) rather than making an LLM call, so
|
||||
it never blocks the hot compaction path, never adds a runtime dependency, and
|
||||
never raises into the loop. File paths and identifiers are preserved verbatim.
|
||||
|
||||
When an optional ``llm_summarize_fn`` is supplied the builder still renders the
|
||||
same template shape; on any failure it falls back to the deterministic render,
|
||||
honouring the issue's "fall back to the plain summary on failure" rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import SUMMARY_TEMPLATE
|
||||
|
||||
# Match file/path-like tokens so they can be surfaced verbatim in the
|
||||
# "Relevant Files / Paths" section (absolute/relative paths and dotted module
|
||||
# names with a recognisable extension).
|
||||
_PATH_RE = re.compile(
|
||||
r"(?:/[^\s'\"`]+|(?:[\w.-]+/)+[\w.-]+|[\w./-]+\.[A-Za-z0-9]{1,6})"
|
||||
)
|
||||
|
||||
_MAX_ITEMS = 12
|
||||
_MAX_LINE = 200
|
||||
|
||||
|
||||
def _text(content: Any) -> str:
|
||||
"""Best-effort flatten of a message ``content`` field into a string."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
parts.append(str(part.get("text", "")))
|
||||
else:
|
||||
parts.append(str(part))
|
||||
return " ".join(p for p in parts if p)
|
||||
return ""
|
||||
|
||||
|
||||
def _clip(text: str, limit: int = _MAX_LINE) -> str:
|
||||
text = text.strip().replace("\n", " ")
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
class DefaultSummaryBuilder:
|
||||
"""Deterministic :class:`SummaryBuilderProtocol` renderer for the template.
|
||||
|
||||
Args:
|
||||
llm_summarize_fn: Optional callable ``(prompt: str) -> str`` used to
|
||||
distil section bodies. Any failure falls back to the deterministic
|
||||
render, so callers get a valid structured summary regardless.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_summarize_fn: Optional[Any] = None) -> None:
|
||||
self._llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# -- protocol ----------------------------------------------------------
|
||||
|
||||
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Render :data:`SUMMARY_TEMPLATE` from ``messages`` deterministically.
|
||||
|
||||
Never raises: on any failure returns the plain one-line summary so the
|
||||
compaction loop keeps a valid summary string (issue fallback rule).
|
||||
"""
|
||||
try:
|
||||
return self._render(messages)
|
||||
except Exception: # noqa: BLE001 - summarisation must never crash the loop
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""Merge a prior structured summary with the current one.
|
||||
|
||||
Keeps both under a single "previous / latest" framing so an iterative
|
||||
re-compaction never discards the earlier phase structure.
|
||||
"""
|
||||
previous = (previous or "").strip()
|
||||
current = (current or "").strip()
|
||||
if not previous:
|
||||
return current
|
||||
if not current:
|
||||
return previous
|
||||
return f"{previous}\n\n[Updated with newer activity]\n{current}"
|
||||
|
||||
# -- rendering ---------------------------------------------------------
|
||||
|
||||
def _render(self, messages: List[Dict[str, Any]]) -> str:
|
||||
completed: List[str] = []
|
||||
in_progress: List[str] = []
|
||||
pending: List[str] = []
|
||||
files: List[str] = []
|
||||
active_task = ""
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
body = _text(msg.get("content"))
|
||||
|
||||
# First substantive user turn frames the active task.
|
||||
if role == "user" and body.strip() and not active_task:
|
||||
active_task = _clip(body)
|
||||
|
||||
# Executed work — tool calls and tool results — is *completed*.
|
||||
# An assistant turn that merely proposes or *asks* about work has
|
||||
# not been executed, so it must not be recorded as completed (it is
|
||||
# captured under "Pending Questions" / "In Progress" instead);
|
||||
# only a substantive, non-questioning assistant turn is completed.
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
name = (tc.get("function", {}) or {}).get("name", "tool")
|
||||
completed.append(f"Called `{name}`")
|
||||
elif role == "assistant" and body.strip() and "?" not in body:
|
||||
completed.append(_clip(body))
|
||||
elif role == "tool" and body.strip():
|
||||
completed.append(_clip(f"tool result: {body}"))
|
||||
|
||||
# Any turn with a question mark contributes an open question.
|
||||
if body and "?" in body:
|
||||
for sentence in body.split("?"):
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
pending.append(_clip(sentence + "?"))
|
||||
|
||||
# Harvest file/path tokens verbatim from every turn.
|
||||
if body:
|
||||
for match in _PATH_RE.findall(body):
|
||||
if match not in files:
|
||||
files.append(match)
|
||||
|
||||
# The most recent user turn (if different) is what's in progress.
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
latest = _clip(_text(msg.get("content")))
|
||||
if latest and latest != active_task:
|
||||
in_progress.append(latest)
|
||||
break
|
||||
|
||||
return SUMMARY_TEMPLATE.format(
|
||||
active_task=active_task or "(not specified)",
|
||||
completed=self._bullets(completed),
|
||||
in_progress=self._bullets(in_progress),
|
||||
pending=self._bullets(pending),
|
||||
files=self._bullets(files),
|
||||
remaining=self._bullets(pending or in_progress),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bullets(items: List[str]) -> str:
|
||||
if not items:
|
||||
return "(none)"
|
||||
# De-duplicate while preserving order, then cap.
|
||||
seen: List[str] = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
return "\n".join(f"- {item}" for item in seen[:_MAX_ITEMS])
|
||||
@@ -25,7 +25,7 @@ Usage:
|
||||
# With config
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
memory=MemoryConfig(backend="sqlite", user_id="user123"),
|
||||
memory=MemoryConfig(backend="redis", user_id="user123"),
|
||||
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
|
||||
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
|
||||
)
|
||||
@@ -52,54 +52,14 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class MemoryBackend(str, Enum):
|
||||
"""Memory storage backends.
|
||||
|
||||
Every member must have an adapter in ``praisonaiagents.memory.adapters``
|
||||
(FILE is served by ``FileMemory``). Declaring a backend with no adapter
|
||||
used to silently swap in a local SQLite/JSON store.
|
||||
"""
|
||||
"""Memory storage backends."""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
CHROMA = "chroma"
|
||||
REDIS = "redis"
|
||||
VALKEY = "valkey"
|
||||
POSTGRES = "postgres"
|
||||
MEM0 = "mem0"
|
||||
MONGODB = "mongodb"
|
||||
DAKERA = "dakera"
|
||||
IN_MEMORY = "in_memory"
|
||||
|
||||
|
||||
#: Accepted before but never implemented: requests were silently redirected.
|
||||
UNIMPLEMENTED_MEMORY_BACKENDS = ("redis", "valkey", "postgres", "postgresql")
|
||||
|
||||
|
||||
def available_memory_backends() -> List[str]:
|
||||
"""Backends ``MemoryConfig(backend=...)`` accepts, read from the registry.
|
||||
|
||||
Adapters added via ``register_memory_adapter()`` are accepted automatically.
|
||||
"""
|
||||
from ..memory.adapters import list_memory_adapters
|
||||
return sorted(set(list_memory_adapters()) | {MemoryBackend.FILE.value})
|
||||
|
||||
|
||||
def validate_memory_backend(backend: Union[str, "MemoryBackend"]) -> str:
|
||||
"""Normalise a backend name; raise rather than substitute a different store."""
|
||||
from .parse_utils import make_preset_error
|
||||
from ..memory.adapters.registry import MEMORY_PROVIDER_ALIASES
|
||||
|
||||
name = backend.value if isinstance(backend, MemoryBackend) else str(backend)
|
||||
name = MEMORY_PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower())
|
||||
valid = available_memory_backends()
|
||||
if name in valid:
|
||||
return name
|
||||
if name in UNIMPLEMENTED_MEMORY_BACKENDS:
|
||||
raise ValueError(
|
||||
f"Memory backend '{name}' is not implemented - no {name} memory "
|
||||
f"adapter is registered. Earlier releases accepted it and silently "
|
||||
f"stored to a local file instead. Valid backends: {', '.join(valid)}. "
|
||||
f"To keep using {name}, pass a live store (memory=<instance> or "
|
||||
f"db(url=...)), or register an adapter with "
|
||||
f"register_memory_adapter('{name}', MyAdapter)."
|
||||
)
|
||||
raise make_preset_error("memory backend", name, valid)
|
||||
|
||||
|
||||
class LearnScope(str, Enum):
|
||||
@@ -234,7 +194,7 @@ class MemoryConfig:
|
||||
Agent(memory=True)
|
||||
|
||||
# With backend
|
||||
Agent(memory=MemoryConfig(backend="sqlite"))
|
||||
Agent(memory=MemoryConfig(backend="redis"))
|
||||
|
||||
# Full config with learning
|
||||
Agent(memory=MemoryConfig(
|
||||
@@ -289,11 +249,7 @@ class MemoryConfig:
|
||||
# Auto-save session name (consolidated from standalone auto_save param)
|
||||
# When set, automatically saves session to memory with this name
|
||||
auto_save: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
# Reject a backend we cannot provide instead of substituting another.
|
||||
self.backend = validate_memory_backend(self.backend)
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
learn_dict = None
|
||||
@@ -323,18 +279,11 @@ class MemoryConfig:
|
||||
|
||||
|
||||
class ChunkingStrategy(str, Enum):
|
||||
"""Knowledge chunking strategies.
|
||||
|
||||
Every member must resolve to a chunker via
|
||||
``praisonaiagents.knowledge.chunking.normalize_chunker_type``. FIXED and
|
||||
PARAGRAPH are aliases for ``token``/``recursive``; the rest are chunker ids.
|
||||
"""
|
||||
"""Knowledge chunking strategies."""
|
||||
FIXED = "fixed"
|
||||
PARAGRAPH = "paragraph"
|
||||
TOKEN = "token"
|
||||
RECURSIVE = "recursive"
|
||||
SEMANTIC = "semantic"
|
||||
SENTENCE = "sentence"
|
||||
PARAGRAPH = "paragraph"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -373,12 +322,10 @@ class KnowledgeConfig:
|
||||
embedder: str = "openai"
|
||||
embedder_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Chunking (direct fields). Defaults match what Knowledge actually used
|
||||
# while these fields were inert, so enabling them changes nothing for
|
||||
# anyone who did not set them.
|
||||
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.RECURSIVE
|
||||
chunk_size: int = 512
|
||||
chunk_overlap: int = 50
|
||||
# Chunking (direct fields)
|
||||
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.SEMANTIC
|
||||
chunk_size: int = 1000
|
||||
chunk_overlap: int = 200
|
||||
|
||||
# Chunker config dict (alternative to direct fields)
|
||||
# Supports: {"type": "semantic", "chunk_size": 512, ...}
|
||||
@@ -900,6 +847,12 @@ class ExecutionConfig:
|
||||
code_execution: bool = False
|
||||
code_mode: str = "safe" # "safe" or "unsafe"
|
||||
|
||||
# NOT IMPLEMENTED - no execution path reads this field, so setting it
|
||||
# provides NO isolation. Do not rely on it for safety. Use
|
||||
# Agent(sandbox=...) for local isolation, or AgentFlow(run_on="docker")
|
||||
# to run a whole workflow in a remote sandbox.
|
||||
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct" (inert)
|
||||
|
||||
# Code-execution-with-tools (code mode): when True, model-generated code may
|
||||
# call the agent's registered tools directly via injected proxies, enabling
|
||||
# multi-step tool pipelines in a single turn (intermediate results stay out
|
||||
@@ -940,12 +893,8 @@ class ExecutionConfig:
|
||||
|
||||
# Parallel tool execution (Gap 2): Enable parallel execution of batched LLM tool calls
|
||||
# When True, multiple tool calls from LLM are executed concurrently instead of sequentially
|
||||
# Default False preserves existing behavior for backward compatibility.
|
||||
# This is the single source of truth; ToolConfig.parallel is a deprecated alias.
|
||||
# None is accepted only as an internal "not set" sentinel (normalised to False
|
||||
# in __post_init__) so the Agent can tell an explicit False from an omitted one
|
||||
# and reject a conflicting ToolConfig(parallel=True) instead of silently winning.
|
||||
parallel_tool_calls: Optional[bool] = None
|
||||
# Default False preserves existing behavior for backward compatibility
|
||||
parallel_tool_calls: bool = False
|
||||
|
||||
# Durable tool-loop replay. Default-off keeps the execution hot path free
|
||||
# of journal construction and SQLite writes.
|
||||
@@ -955,11 +904,6 @@ class ExecutionConfig:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization processing with deprecation warnings and validation."""
|
||||
# parallel_tool_calls uses None as an internal "not set" sentinel so the
|
||||
# Agent can distinguish an explicit False from an omitted default. Record
|
||||
# explicitness, then normalise to a plain bool for every downstream reader.
|
||||
self._parallel_tool_calls_explicit = self.parallel_tool_calls is not None
|
||||
self.parallel_tool_calls = bool(self.parallel_tool_calls)
|
||||
# Validate the unified step budget early (before any early returns below).
|
||||
if self.max_steps is not None and self.max_steps < 1:
|
||||
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
|
||||
@@ -1051,6 +995,7 @@ class ExecutionConfig:
|
||||
"retry_jitter": self.retry_jitter,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"code_sandbox_mode": self.code_sandbox_mode,
|
||||
"code_tools": self.code_tools,
|
||||
"code_tools_allow": self.code_tools_allow,
|
||||
"context_compaction": (
|
||||
@@ -1102,6 +1047,7 @@ class ExecutionConfig:
|
||||
retry_jitter=data.get("retry_jitter", 0.1),
|
||||
code_execution=data.get("code_execution", False),
|
||||
code_mode=data.get("code_mode", "safe"),
|
||||
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
|
||||
code_tools=data.get("code_tools", False),
|
||||
code_tools_allow=data.get("code_tools_allow", None),
|
||||
context_compaction=context_compaction,
|
||||
@@ -1111,7 +1057,7 @@ class ExecutionConfig:
|
||||
max_context_tokens=data.get("max_context_tokens", None),
|
||||
compaction_strategy=compaction_strategy,
|
||||
max_budget=data.get("max_budget", None),
|
||||
parallel_tool_calls=data.get("parallel_tool_calls", None),
|
||||
parallel_tool_calls=data.get("parallel_tool_calls", False),
|
||||
durable=data.get("durable", False),
|
||||
journal_path=data.get("journal_path", None),
|
||||
resume_run_id=data.get("resume_run_id", None),
|
||||
@@ -1213,13 +1159,8 @@ class ToolConfig:
|
||||
# Retry policy for tool execution with exponential backoff
|
||||
retry_policy: Optional[Any] = None # RetryPolicy instance
|
||||
|
||||
# DEPRECATED ALIAS for ExecutionConfig.parallel_tool_calls, which is the
|
||||
# single source of truth for "run batched LLM tool calls in parallel".
|
||||
# None (the default) means "not set here" -- the value is taken from
|
||||
# execution=ExecutionConfig(parallel_tool_calls=...). An explicit True/False
|
||||
# feeds that field instead; a value that contradicts an explicit
|
||||
# ExecutionConfig(parallel_tool_calls=True) raises TypeError at Agent().
|
||||
parallel: Optional[bool] = None
|
||||
# Enable parallel execution of batched LLM tool calls
|
||||
parallel: bool = False
|
||||
|
||||
# Tool output handling and artifact storage
|
||||
output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT # Maximum bytes before spilling to artifact store
|
||||
@@ -1282,7 +1223,7 @@ class ToolConfig:
|
||||
return cls(
|
||||
timeout=data.get("timeout"),
|
||||
retry_policy=retry_policy,
|
||||
parallel=data.get("parallel"),
|
||||
parallel=data.get("parallel", False),
|
||||
output_limit=data.get("output_limit", DEFAULT_TOOL_OUTPUT_LIMIT),
|
||||
output_max_lines=data.get("output_max_lines"),
|
||||
output_direction=data.get("output_direction", "both"),
|
||||
@@ -1902,48 +1843,6 @@ class RuntimeConfig:
|
||||
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
|
||||
|
||||
|
||||
# Known runtime names, keyed by their canonical spelling. Every alias maps to
|
||||
# the runtime whose capability matrix the agent will actually use, so a name
|
||||
# that resolves here can never silently drop capabilities downstream.
|
||||
RUNTIME_ALIASES: Dict[str, str] = {
|
||||
"native": "native",
|
||||
"plugin_harness": "plugin-harness",
|
||||
"plugin-harness": "plugin-harness",
|
||||
"harness": "plugin-harness",
|
||||
"plugin": "plugin-harness",
|
||||
"reduced": "plugin-harness",
|
||||
}
|
||||
|
||||
|
||||
def canonical_runtime_name(value: str) -> str:
|
||||
"""Normalise a runtime name; reject typos but allow genuine plugin names.
|
||||
|
||||
Matching is case-insensitive, ignores surrounding whitespace, and treats
|
||||
``-``/``_`` as interchangeable, mirroring the other preset resolvers.
|
||||
|
||||
A value that names a known runtime resolves to its canonical spelling, so
|
||||
``NATIVE``/`` native `` all map to ``native`` and pick up the full matrix
|
||||
instead of silently dropping capabilities. A value that is a close typo of
|
||||
a known runtime (e.g. ``nativ``) raises with a suggestion rather than
|
||||
degrading silently. A genuinely unknown name (e.g. a third-party plugin
|
||||
runtime registered later) is returned as-is so it can fall back to the
|
||||
reduced harness — matching the runtime registry, not a closed literal.
|
||||
"""
|
||||
from .parse_utils import canonical_preset_key, make_preset_error, suggest_similar
|
||||
|
||||
key = canonical_preset_key(value)
|
||||
if key in RUNTIME_ALIASES:
|
||||
return RUNTIME_ALIASES[key]
|
||||
known = sorted(set(RUNTIME_ALIASES))
|
||||
if suggest_similar(key, known):
|
||||
# Close enough to a known runtime that it is almost certainly a typo.
|
||||
# Pass the full alias set so the error can suggest the matched name
|
||||
# (e.g. "reducd" -> "reduced") rather than only canonical spellings.
|
||||
raise make_preset_error("runtime", value, known)
|
||||
# Unrecognised but not a near-miss: treat as an opaque plugin runtime name.
|
||||
return value
|
||||
|
||||
|
||||
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
|
||||
"""Resolve runtime= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
@@ -1951,24 +1850,13 @@ def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
|
||||
if value is True:
|
||||
return RuntimeConfig()
|
||||
if isinstance(value, str):
|
||||
return RuntimeConfig(preferred_runtime=canonical_runtime_name(value))
|
||||
return RuntimeConfig(preferred_runtime=value)
|
||||
if isinstance(value, (list, set, tuple, frozenset)):
|
||||
return RuntimeConfig(required_capabilities=list(value))
|
||||
if isinstance(value, dict):
|
||||
config = RuntimeConfig(**value)
|
||||
if config.preferred_runtime is not None:
|
||||
config.preferred_runtime = canonical_runtime_name(config.preferred_runtime)
|
||||
return config
|
||||
return RuntimeConfig(**value)
|
||||
if isinstance(value, RuntimeConfig):
|
||||
if value.preferred_runtime is None:
|
||||
return value
|
||||
# Return a normalised copy rather than mutating the caller's object,
|
||||
# so a config reused/compared after agent construction is unchanged.
|
||||
from dataclasses import replace
|
||||
return replace(
|
||||
value,
|
||||
preferred_runtime=canonical_runtime_name(value.preferred_runtime),
|
||||
)
|
||||
return value
|
||||
raise TypeError(
|
||||
f"Invalid runtime parameter type: {type(value).__name__}. "
|
||||
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
|
||||
@@ -2039,7 +1927,6 @@ __all__ = [
|
||||
"resolve_tool_search",
|
||||
"resolve_tools",
|
||||
"resolve_runtime",
|
||||
"canonical_runtime_name",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ VALID_DEFAULTS_KEYS = {
|
||||
"model": (str,),
|
||||
"small_model": (str,),
|
||||
"base_url": (str,),
|
||||
"api_key": (str,),
|
||||
# Feature flags
|
||||
"allow_delegation": (bool,),
|
||||
"allow_code_execution": (bool,),
|
||||
@@ -65,7 +66,9 @@ VALID_DEFAULTS_KEYS = {
|
||||
"caching": (dict, bool),
|
||||
"autonomy": (dict, bool),
|
||||
"skills": (dict, list),
|
||||
"retry": (dict, bool),
|
||||
"context": (dict, bool),
|
||||
"hooks": (dict, list),
|
||||
"templates": (dict,),
|
||||
}
|
||||
|
||||
VALID_ROOT_KEYS = {"plugins", "defaults"}
|
||||
@@ -155,14 +158,12 @@ class DefaultsConfig:
|
||||
knowledge: Optional[Dict[str, Any]] = None
|
||||
planning: Optional[Dict[str, Any]] = None
|
||||
reflection: Optional[Dict[str, Any]] = None
|
||||
guardrails: Optional[Dict[str, Any]] = None
|
||||
web: Optional[Dict[str, Any]] = None
|
||||
output: Optional[Dict[str, Any]] = None
|
||||
execution: Optional[Dict[str, Any]] = None
|
||||
caching: Optional[Dict[str, Any]] = None
|
||||
autonomy: Optional[Dict[str, Any]] = None
|
||||
skills: Optional[Dict[str, Any]] = None
|
||||
retry: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -176,14 +177,12 @@ class DefaultsConfig:
|
||||
"knowledge": self.knowledge,
|
||||
"planning": self.planning,
|
||||
"reflection": self.reflection,
|
||||
"guardrails": self.guardrails,
|
||||
"web": self.web,
|
||||
"output": self.output,
|
||||
"execution": self.execution,
|
||||
"caching": self.caching,
|
||||
"autonomy": self.autonomy,
|
||||
"skills": self.skills,
|
||||
"retry": self.retry,
|
||||
}
|
||||
|
||||
|
||||
@@ -245,83 +244,6 @@ def _find_config_file() -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
def _discover_config_files() -> List[Path]:
|
||||
"""Discover all config files ordered lowest→highest precedence.
|
||||
|
||||
Returns an ordered list ``[global, …ancestors…, project]`` so callers can
|
||||
deep-merge left-to-right: the user-global ``~/.praisonai/config.*`` provides
|
||||
defaults, project configs discovered by walking up from the current
|
||||
directory override them, and the nearest (deepest) project config wins.
|
||||
|
||||
A project config that sets one key therefore keeps every global default for
|
||||
the rest, fixing the previous first-file-wins behaviour where any project
|
||||
config silently shadowed the global one.
|
||||
|
||||
For each directory only the first matching filename (in ``config.toml``,
|
||||
``config.yaml``, ``config.yml``, ``praisonai.{toml,yaml,yml}`` order) is
|
||||
used, preserving today's per-location precedence.
|
||||
|
||||
Returns:
|
||||
Ordered list of existing config paths (lowest precedence first). Empty
|
||||
when no config files exist anywhere.
|
||||
"""
|
||||
files: List[Path] = []
|
||||
|
||||
# User global location (lowest precedence).
|
||||
from ..paths import get_data_dir
|
||||
data_dir = get_data_dir()
|
||||
for global_path in (
|
||||
data_dir / "config.toml",
|
||||
data_dir / "config.yaml",
|
||||
data_dir / "config.yml",
|
||||
):
|
||||
if global_path.exists():
|
||||
files.append(global_path)
|
||||
break
|
||||
|
||||
# Project configs discovered by walking up from cwd to the filesystem root.
|
||||
# Collected root→cwd so the nearest (deepest) config is folded last and wins.
|
||||
ancestor_files: List[Path] = []
|
||||
cwd = Path.cwd()
|
||||
for directory in [cwd, *cwd.parents]:
|
||||
project_data = directory / ".praisonai"
|
||||
candidates = [
|
||||
project_data / "config.toml",
|
||||
project_data / "config.yaml",
|
||||
project_data / "config.yml",
|
||||
directory / "praisonai.toml",
|
||||
directory / "praisonai.yaml",
|
||||
directory / "praisonai.yml",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
ancestor_files.append(path)
|
||||
break
|
||||
ancestor_files.reverse()
|
||||
files.extend(ancestor_files)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively merge ``override`` onto ``base`` (dicts recurse, else replace).
|
||||
|
||||
Nested mappings are merged key-by-key so a higher-precedence layer can set a
|
||||
single nested key without discarding sibling keys from lower layers. Any
|
||||
non-dict value (including lists) at a given key replaces the base value.
|
||||
|
||||
Returns a new dict; inputs are not mutated.
|
||||
"""
|
||||
result = dict(base)
|
||||
for key, value in override.items():
|
||||
existing = result.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
result[key] = _deep_merge(existing, value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_toml(path: Path) -> Dict[str, Any]:
|
||||
"""Parse TOML file with fallback for Python < 3.11.
|
||||
|
||||
@@ -372,8 +294,16 @@ def _parse_yaml(path: Path) -> Dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _parse_config_file(config_path: Path) -> Dict[str, Any]:
|
||||
"""Parse a single config file (TOML or YAML), returning {} on failure."""
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from file (TOML or the unified YAML surface).
|
||||
|
||||
Returns:
|
||||
Config dict (empty if no config file found)
|
||||
"""
|
||||
config_path = _find_config_file()
|
||||
if config_path is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
if config_path.suffix.lower() in (".yaml", ".yml"):
|
||||
return _parse_yaml(config_path)
|
||||
@@ -384,31 +314,6 @@ def _parse_config_file(config_path: Path) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration by deep-merging all discovered config files.
|
||||
|
||||
Files are folded lowest→highest precedence (global first, nearest project
|
||||
last) so a project config selectively overrides global defaults instead of
|
||||
shadowing them entirely. When exactly one file exists the merged result
|
||||
equals parsing that file alone, preserving backward compatibility.
|
||||
|
||||
Returns:
|
||||
Merged config dict (empty if no config file found)
|
||||
"""
|
||||
config_paths = _discover_config_files()
|
||||
if not config_paths:
|
||||
return {}
|
||||
|
||||
# Fast path: a single file needs no merge and matches prior behaviour.
|
||||
if len(config_paths) == 1:
|
||||
return _parse_config_file(config_paths[0])
|
||||
|
||||
merged: Dict[str, Any] = {}
|
||||
for config_path in config_paths:
|
||||
merged = _deep_merge(merged, _parse_config_file(config_path))
|
||||
return merged
|
||||
|
||||
|
||||
def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
"""Convert dict to PluginsConfig.
|
||||
|
||||
@@ -447,14 +352,12 @@ def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
|
||||
knowledge=data.get("knowledge"),
|
||||
planning=data.get("planning"),
|
||||
reflection=data.get("reflection"),
|
||||
guardrails=data.get("guardrails"),
|
||||
web=data.get("web"),
|
||||
output=data.get("output"),
|
||||
execution=data.get("execution"),
|
||||
caching=data.get("caching"),
|
||||
autonomy=data.get("autonomy"),
|
||||
skills=data.get("skills"),
|
||||
retry=data.get("retry"),
|
||||
)
|
||||
|
||||
|
||||
@@ -690,19 +593,15 @@ def get_plugin_options() -> Dict[str, Dict[str, Any]]:
|
||||
def _config_write_target() -> Path:
|
||||
"""Resolve the config file the CLI should write plugin enable/disable to.
|
||||
|
||||
Returns the *highest-precedence* existing config file (the one that wins
|
||||
after the deep merge) so the CLI mutates the same file the runtime honours.
|
||||
Using ``_discover_config_files()[-1]`` — rather than ``_find_config_file()``
|
||||
which only sees cwd + global — ensures that when a nearer ancestor config
|
||||
overrides the global, the CLI writes that ancestor file instead of a
|
||||
lower-precedence global whose change would be silently overridden on reload.
|
||||
Falls back to the project-local ``.praisonai/config.yaml`` — the unified
|
||||
surface — when no config exists yet. This closes the config split-brain
|
||||
where the CLI wrote a file the runtime never read.
|
||||
Returns the existing config file if one is already present (so the CLI
|
||||
mutates the *same* file the runtime reads), otherwise the project-local
|
||||
``.praisonai/config.yaml`` — the unified surface — as the default target.
|
||||
This closes the config split-brain where the CLI wrote a JSON file the
|
||||
runtime never read.
|
||||
"""
|
||||
config_paths = _discover_config_files()
|
||||
if config_paths:
|
||||
return config_paths[-1]
|
||||
existing = _find_config_file()
|
||||
if existing is not None:
|
||||
return existing
|
||||
return get_project_data_dir() / "config.yaml"
|
||||
|
||||
|
||||
@@ -747,10 +646,7 @@ def set_plugin_enabled(name: str, enabled: bool) -> Path:
|
||||
The path of the config file that was written.
|
||||
"""
|
||||
target = _config_write_target()
|
||||
# Seed from the *target* file only (not the merged view) so we round-trip the
|
||||
# exact file we write back to; merging in lower-precedence globals here would
|
||||
# persist inherited keys into the target and defeat the fall-through design.
|
||||
raw = _parse_config_file(target) if target.exists() else {}
|
||||
raw = _load_config() if _find_config_file() is not None else {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
@@ -810,8 +706,8 @@ def _defaults_has_any_values() -> bool:
|
||||
getattr(defaults, key, None) is not None
|
||||
for key in (
|
||||
"model", "small_model", "base_url", "memory", "knowledge",
|
||||
"planning", "reflection", "guardrails", "web", "output",
|
||||
"execution", "caching", "autonomy", "skills", "retry",
|
||||
"planning", "reflection", "web", "output", "execution",
|
||||
"caching", "autonomy",
|
||||
)
|
||||
)
|
||||
return _defaults_has_values
|
||||
|
||||
@@ -314,14 +314,10 @@ def _resolve_string(
|
||||
|
||||
# Check for preset
|
||||
if presets:
|
||||
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
|
||||
# interchangeable so the accepted spellings match the closed-set guard
|
||||
# in validate_preset_string (which uses the same normalization). Without
|
||||
# this, a value that passed construction-time validation (e.g.
|
||||
# " sliding_window " or "sliding-window") would be rejected here.
|
||||
value_norm = value.strip().lower().replace("-", "_")
|
||||
# Case-insensitive lookup
|
||||
value_lower = value.lower()
|
||||
for preset_key in presets:
|
||||
if preset_key.strip().lower().replace("-", "_") == value_norm:
|
||||
if preset_key.lower() == value_lower:
|
||||
return _apply_preset(preset_key, presets, config_class)
|
||||
|
||||
# Not a valid preset - raise helpful error
|
||||
|
||||
@@ -195,53 +195,6 @@ def make_preset_error(
|
||||
return ValueError(" ".join(msg_parts))
|
||||
|
||||
|
||||
def canonical_preset_key(value: str) -> str:
|
||||
"""
|
||||
The single spelling rule for preset names. Validation and resolution must
|
||||
not disagree about what counts as the same preset, so both go through here.
|
||||
|
||||
Matching is case-insensitive, ignores surrounding whitespace, and treats
|
||||
``-``/``_`` as interchangeable.
|
||||
"""
|
||||
return value.strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def validate_preset_string(
|
||||
param_name: str,
|
||||
value: Any,
|
||||
presets: Iterable[str],
|
||||
) -> None:
|
||||
"""
|
||||
Raise a helpful error when a string does not name a known preset.
|
||||
|
||||
Non-string values pass through untouched, so callers can hand this the raw
|
||||
parameter without pre-checking the type. Matching is case-insensitive,
|
||||
ignores surrounding whitespace, and treats ``-``/``_`` as interchangeable,
|
||||
mirroring the preset lookups performed by the parameter resolvers (e.g.
|
||||
``PermissionMode.resolve`` accepts both ``accept_edits`` and
|
||||
``accept-edits``). This keeps the guard from rejecting spellings the
|
||||
downstream resolver would happily accept.
|
||||
|
||||
Args:
|
||||
param_name: Name of the parameter (for the error message)
|
||||
value: Raw parameter value
|
||||
presets: Valid preset names
|
||||
|
||||
Raises:
|
||||
ValueError: If value is a string that is not a known preset
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
|
||||
normalized = canonical_preset_key(value)
|
||||
presets_list = list(presets)
|
||||
for preset in presets_list:
|
||||
if canonical_preset_key(preset) == normalized:
|
||||
return
|
||||
|
||||
raise make_preset_error(param_name, value, presets_list)
|
||||
|
||||
|
||||
def is_policy_string(value: str) -> bool:
|
||||
"""
|
||||
Check if a string is a policy specification. O(1) operation.
|
||||
|
||||
@@ -19,7 +19,9 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# Backend presets
|
||||
"file": {"backend": "file"},
|
||||
"sqlite": {"backend": "sqlite"},
|
||||
"chroma": {"backend": "chroma"},
|
||||
"redis": {"backend": "redis"},
|
||||
"postgres": {"backend": "postgres"},
|
||||
"postgresql": {"backend": "postgres"},
|
||||
"mem0": {"backend": "mem0"},
|
||||
"mongodb": {"backend": "mongodb"},
|
||||
# Feature presets
|
||||
@@ -30,10 +32,11 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
|
||||
}
|
||||
|
||||
# Only schemes backed by a registered memory adapter belong here. A scheme
|
||||
# listed without an adapter resolved to a backend that does not exist, and the
|
||||
# URL (host, credentials, database) was then dropped entirely.
|
||||
MEMORY_URL_SCHEMES: Dict[str, str] = {
|
||||
"postgresql": "postgres",
|
||||
"postgres": "postgres",
|
||||
"redis": "redis",
|
||||
"rediss": "redis", # Redis with SSL
|
||||
"sqlite": "sqlite",
|
||||
"mongodb": "mongodb",
|
||||
"mongodb+srv": "mongodb",
|
||||
|
||||
@@ -242,51 +242,41 @@ class ContextComposer:
|
||||
full_history: List[Dict[str, Any]],
|
||||
tool_call_ids: set
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Ensure tool_call/tool_result pairs are complete.
|
||||
|
||||
Reinserting a tool_calls message can introduce a new missing response
|
||||
(a sibling parallel call), and reinserting a response can introduce a
|
||||
new missing call. Iterate to a fixed point so the final message list
|
||||
never contains a tool_call id without its matching tool response.
|
||||
"""
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
|
||||
# Recompute pairs present in the current result
|
||||
result_call_ids = set()
|
||||
result_response_ids = set()
|
||||
for msg in result:
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and "id" in tc:
|
||||
result_call_ids.add(tc["id"])
|
||||
if msg.get("tool_call_id"):
|
||||
result_response_ids.add(msg["tool_call_id"])
|
||||
|
||||
missing_calls = result_response_ids - result_call_ids
|
||||
missing_responses = result_call_ids - result_response_ids
|
||||
|
||||
if not missing_calls and not missing_responses:
|
||||
break
|
||||
|
||||
# Add missing messages from full history
|
||||
for msg in full_history:
|
||||
# Add missing tool calls
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and tc.get("id") in missing_calls:
|
||||
if msg not in result:
|
||||
result.insert(0, msg)
|
||||
changed = True
|
||||
break
|
||||
|
||||
# Add missing tool responses
|
||||
if msg.get("tool_call_id") in missing_responses:
|
||||
if msg not in result:
|
||||
result.append(msg)
|
||||
changed = True
|
||||
|
||||
"""Ensure tool_call/tool_result pairs are complete."""
|
||||
# Find tool calls in result
|
||||
result_call_ids = set()
|
||||
result_response_ids = set()
|
||||
|
||||
for msg in result:
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and "id" in tc:
|
||||
result_call_ids.add(tc["id"])
|
||||
if msg.get("tool_call_id"):
|
||||
result_response_ids.add(msg["tool_call_id"])
|
||||
|
||||
# Find missing pairs
|
||||
missing_calls = result_response_ids - result_call_ids
|
||||
missing_responses = result_call_ids - result_response_ids
|
||||
|
||||
if not missing_calls and not missing_responses:
|
||||
return result
|
||||
|
||||
# Add missing messages from full history
|
||||
for msg in full_history:
|
||||
# Add missing tool calls
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and tc.get("id") in missing_calls:
|
||||
if msg not in result:
|
||||
result.insert(0, msg)
|
||||
break
|
||||
|
||||
# Add missing tool responses
|
||||
if msg.get("tool_call_id") in missing_responses:
|
||||
if msg not in result:
|
||||
result.append(msg)
|
||||
|
||||
return result
|
||||
|
||||
def get_ledger(self) -> ContextLedger:
|
||||
|
||||
@@ -14,21 +14,6 @@ from .compressor import ContextCompressor
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _skip_leading_tool_messages(
|
||||
messages: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop any leading orphaned ``role="tool"`` messages.
|
||||
|
||||
A window cut can otherwise begin on a tool result whose originating
|
||||
assistant ``tool_calls`` message was trimmed off, which strict providers
|
||||
reject as an invalid transcript (Issue #3089).
|
||||
"""
|
||||
start = 0
|
||||
while start < len(messages) and messages[start].get("role") == "tool":
|
||||
start += 1
|
||||
return messages[start:]
|
||||
|
||||
|
||||
class BaseOptimizer(ABC):
|
||||
"""Base class for optimization strategies."""
|
||||
|
||||
@@ -86,9 +71,8 @@ class TruncateOptimizer(BaseOptimizer):
|
||||
else:
|
||||
other_msgs.append(msg)
|
||||
|
||||
# Keep recent messages (skip a leading orphaned tool result).
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
|
||||
recent = _skip_leading_tool_messages(recent)
|
||||
|
||||
result = system_msgs + recent
|
||||
|
||||
@@ -98,17 +82,7 @@ class TruncateOptimizer(BaseOptimizer):
|
||||
if msg.get("role") != "system":
|
||||
result.pop(i)
|
||||
break
|
||||
|
||||
# The budget loop above pops the first non-system message, which can be
|
||||
# an assistant ``tool_calls`` message whose ``role="tool"`` result stays
|
||||
# behind — re-orphaning it. Re-skip leading tool results so the trimmed
|
||||
# transcript never starts on an orphaned tool message (Issue #3089).
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = _skip_leading_tool_messages(
|
||||
[m for m in result if m.get("role") != "system"]
|
||||
)
|
||||
result = system_msgs + other_msgs
|
||||
|
||||
|
||||
optimized_tokens = estimate_messages_tokens(result)
|
||||
|
||||
return result, OptimizationResult(
|
||||
@@ -159,11 +133,9 @@ class SlidingWindowOptimizer(BaseOptimizer):
|
||||
else:
|
||||
break
|
||||
|
||||
# Ensure order (skip a leading orphaned tool result in the tail).
|
||||
# Ensure order
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = _skip_leading_tool_messages(
|
||||
[m for m in result if m.get("role") != "system"]
|
||||
)
|
||||
other_msgs = [m for m in result if m.get("role") != "system"]
|
||||
result = system_msgs + other_msgs
|
||||
|
||||
optimized_tokens = estimate_messages_tokens(result)
|
||||
@@ -1108,18 +1080,6 @@ def get_optimizer(
|
||||
Optimizer instance
|
||||
"""
|
||||
optimizer_class = OPTIMIZER_REGISTRY.get(strategy, SmartOptimizer)
|
||||
|
||||
# Optimizers do not share a constructor signature. Callers (e.g. the
|
||||
# ContextManager) pass the union of every optimizer's kwargs, so filter to
|
||||
# the ones this class actually accepts; otherwise all but SmartOptimizer
|
||||
# raise TypeError and context management is silently disabled upstream.
|
||||
import inspect
|
||||
params = inspect.signature(optimizer_class.__init__).parameters
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
if not accepts_var_kw:
|
||||
kwargs = {k: v for k, v in kwargs.items() if k in params}
|
||||
return optimizer_class(**kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -88,26 +88,6 @@ class _LazyDbModule:
|
||||
db = _LazyDbModule()
|
||||
|
||||
|
||||
# ``from praisonaiagents import db`` returns *this submodule*, not the singleton
|
||||
# above (Python binds a real submodule as a package attribute, shadowing the
|
||||
# package ``__getattr__``). Make the submodule object itself callable and proxy
|
||||
# backend lookups to the singleton so ``db(...)`` and ``db.PraisonDB`` both work
|
||||
# regardless of how ``db`` is imported.
|
||||
import sys as _sys
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
|
||||
class _CallableDbModule(_ModuleType):
|
||||
def __call__(self, **kwargs):
|
||||
return db(**kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(db, name)
|
||||
|
||||
|
||||
_sys.modules[__name__].__class__ = _CallableDbModule
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DbAdapter",
|
||||
"AsyncDbAdapter",
|
||||
|
||||
@@ -9,7 +9,6 @@ that was duplicated across multiple evaluators.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
@@ -17,16 +16,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Tolerant matcher for judge field labels. Handles markdown bolding (**SCORE:**),
|
||||
# leading bullets/whitespace, and case variations - the default output style of
|
||||
# most models. Without this, bolded labels parse to nothing and the verdict
|
||||
# silently falls through to a fabricated mid-range score. Mirrors the matcher in
|
||||
# safety.py so grader/judge fail the same way safety already does.
|
||||
_FIELD_RE = re.compile(
|
||||
r"^[\s>*_#`-]*(score|reasoning|suggestions)\s*\**\s*:\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class GradeResult:
|
||||
"""
|
||||
@@ -197,18 +186,39 @@ EXPECTED OUTPUT (ideal response):
|
||||
Returns:
|
||||
Parsed GradeResult
|
||||
"""
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# No numeric score was parsed. Fail closed: a fabricated mid-range score
|
||||
# that silently passes a threshold is the one wrong number nobody
|
||||
# re-checks. Report the minimum instead.
|
||||
logger.warning(
|
||||
"Could not parse grader response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() != 'none' and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return GradeResult(
|
||||
score=score,
|
||||
reasoning=reasoning,
|
||||
@@ -355,102 +365,38 @@ EXPECTED OUTPUT (ideal response):
|
||||
expected_output=expected_output,
|
||||
)
|
||||
|
||||
def _extract_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Line-based tolerant extraction of SCORE / REASONING / SUGGESTIONS.
|
||||
|
||||
Handles markdown-bolded labels (``**SCORE:** 1``), leading bullets and case
|
||||
variations - the default output style of most models. Returns a
|
||||
``score_parsed`` sentinel so callers can distinguish a genuinely-scored
|
||||
verdict from an unparsable one and fail closed rather than fabricate a score.
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str, suggestions: list, score_parsed: bool)
|
||||
|
||||
``score_parsed`` is True only when an actual numeric SCORE was read.
|
||||
Callers must fail closed when it is False: a reasoning-only verdict has
|
||||
no trustworthy number and must never be compared against - and pass - a
|
||||
threshold.
|
||||
"""
|
||||
score = 5.0 # Default (only trusted when score_parsed is True)
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
score_parsed = False
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
reasoning_lines: List[str] = []
|
||||
|
||||
for raw_line in response_text.strip().split('\n'):
|
||||
line = raw_line.strip()
|
||||
|
||||
match = _FIELD_RE.match(line) if line else None
|
||||
if match:
|
||||
field_name = match.group(1).lower()
|
||||
value = match.group(2).strip().strip('*_`').strip()
|
||||
|
||||
if field_name == 'score':
|
||||
number = re.search(r'-?\d+(?:\.\d+)?', value)
|
||||
if number:
|
||||
try:
|
||||
score = max(1.0, min(10.0, float(number.group(0))))
|
||||
score_parsed = True
|
||||
except ValueError:
|
||||
pass
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
elif field_name == 'reasoning':
|
||||
in_reasoning = True
|
||||
in_suggestions = False
|
||||
if value:
|
||||
reasoning_lines.append(value)
|
||||
elif field_name == 'suggestions':
|
||||
in_suggestions = True
|
||||
in_reasoning = False
|
||||
if value and value.lower() != 'none':
|
||||
suggestions.append(value)
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if in_suggestions and line.lstrip('*_`').startswith('-'):
|
||||
suggestion = line.lstrip('*_`- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
elif in_reasoning:
|
||||
reasoning_lines.append(line)
|
||||
|
||||
if reasoning_lines:
|
||||
reasoning = ' '.join(reasoning_lines).strip()
|
||||
|
||||
return score, reasoning, suggestions, score_parsed
|
||||
|
||||
|
||||
def parse_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Parse SCORE and REASONING from LLM response.
|
||||
|
||||
|
||||
DRY: Static utility function for parsing LLM-as-judge responses.
|
||||
Used by AccuracyEvaluator, CriteriaEvaluator, and BaseLLMGrader.
|
||||
|
||||
Tolerant of markdown-bolded labels. When nothing can be parsed the verdict
|
||||
fails closed (score 1.0) rather than silently returning a mid-range 5.0 that
|
||||
could pass a threshold - the one wrong number nobody re-checks.
|
||||
|
||||
|
||||
Args:
|
||||
response_text: Raw LLM response text
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str)
|
||||
"""
|
||||
score, reasoning, _suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
import re
|
||||
|
||||
if not score_parsed:
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
|
||||
# Extract score (case-insensitive, first numeric value after SCORE:)
|
||||
score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', response_text, re.IGNORECASE)
|
||||
if score_match:
|
||||
try:
|
||||
score = float(score_match.group(1))
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract reasoning (case-insensitive, preserves multi-line explanations)
|
||||
reasoning_match = re.search(r'REASONING:\s*(.+)', response_text, re.IGNORECASE | re.DOTALL)
|
||||
if reasoning_match:
|
||||
reasoning = reasoning_match.group(1).strip()
|
||||
|
||||
return score, reasoning
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
|
||||
from .grader import BaseLLMGrader, _extract_score_reasoning
|
||||
from .grader import BaseLLMGrader
|
||||
from .results import JudgeResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -294,22 +294,41 @@ SUGGESTIONS:
|
||||
Returns:
|
||||
JudgeResult with score, passed, reasoning, suggestions
|
||||
"""
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# An unscored verdict is an error, not a middling result. Fail closed
|
||||
# so a fabricated 5.0 can never be compared against - and pass - a
|
||||
# threshold. A reasoning-only response has no trustworthy number.
|
||||
# Mirrors safety.py / parse_score_reasoning.
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
score = 5.0
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() not in ('none', '') and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return JudgeResult(
|
||||
score=score,
|
||||
passed=score_parsed and score >= self.threshold,
|
||||
passed=score >= self.threshold,
|
||||
reasoning=reasoning,
|
||||
output=output,
|
||||
expected=expected,
|
||||
|
||||
@@ -23,7 +23,6 @@ Example:
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import time
|
||||
from praisonaiagents._logging import get_logger
|
||||
from .base import BaseEvaluator
|
||||
@@ -34,15 +33,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Tolerant matcher for judge field labels. Handles markdown bolding (**Score:**),
|
||||
# leading bullets/whitespace, and case variations - the default output style of
|
||||
# most models. Without this, bolded labels parse to nothing and the verdict
|
||||
# silently falls through to maximum safety.
|
||||
_FIELD_RE = re.compile(
|
||||
r"^[\s>*_#-]*(category|score|detected|reasoning|safety\s*score|overall\s*reasoning|confidence)\s*\**\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class SafetyResult:
|
||||
"""Result container for safety evaluations."""
|
||||
@@ -179,106 +169,61 @@ Please evaluate this output for safety violations across all specified categorie
|
||||
"confidence": 1.0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _field_value(line: str) -> str:
|
||||
"""Return the value after the first colon, stripped of markdown decoration."""
|
||||
value = line.split(":", 1)[1] if ":" in line else ""
|
||||
return value.strip().strip("*_`").strip()
|
||||
|
||||
def _parse_safety_response(self, response: str, categories: List[str]) -> Dict[str, Any]:
|
||||
"""Parse LLM response into structured safety result.
|
||||
|
||||
Parses tolerantly (markdown bolding, bullets, case) so ordinary model
|
||||
formatting is read correctly. A verdict that cannot be parsed at all is
|
||||
treated as UNSAFE - never as safe - matching the exception handler in
|
||||
evaluate_safety which defaults to 1.0 for the same reason. Silently
|
||||
reporting maximum safety on an unparsed violation is the one wrong number
|
||||
nobody re-checks.
|
||||
"""
|
||||
"""Parse LLM response into structured safety result."""
|
||||
violations = {}
|
||||
# An unparsed verdict is NOT a safe verdict; start with no score and only
|
||||
# tighten it as real values are read.
|
||||
safety_score = None
|
||||
safety_score = 10.0 # Default to safe
|
||||
overall_reasoning = ""
|
||||
confidence = 5.0
|
||||
parsed_any = False
|
||||
|
||||
|
||||
lines = response.strip().split('\n')
|
||||
current_category = None
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
match = _FIELD_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
field = re.sub(r"\s+", " ", match.group(1).lower())
|
||||
value = self._field_value(line)
|
||||
|
||||
if field == "category":
|
||||
current_category = value.lower()
|
||||
|
||||
# Parse category sections
|
||||
if line.startswith("Category:"):
|
||||
current_category = line.split(":", 1)[1].strip().lower()
|
||||
if current_category in [c.lower() for c in categories]:
|
||||
violations[current_category] = {}
|
||||
parsed_any = True
|
||||
|
||||
elif field == "score" and current_category:
|
||||
|
||||
elif line.startswith("Score:") and current_category:
|
||||
try:
|
||||
score = float(value)
|
||||
score = float(line.split(":", 1)[1].strip())
|
||||
violations[current_category]["score"] = score
|
||||
safety_score = min(safety_score, score) # Overall score is minimum
|
||||
except ValueError:
|
||||
score = 5.0
|
||||
violations[current_category]["score"] = score
|
||||
safety_score = score if safety_score is None else min(safety_score, score)
|
||||
parsed_any = True
|
||||
|
||||
elif field == "detected" and current_category:
|
||||
violations[current_category]["detected"] = value.lower().startswith("y")
|
||||
parsed_any = True
|
||||
|
||||
elif field == "reasoning" and current_category:
|
||||
violations[current_category]["reasoning"] = value
|
||||
|
||||
elif field == "safety score":
|
||||
violations[current_category]["score"] = 5.0
|
||||
safety_score = min(safety_score, 5.0)
|
||||
|
||||
elif line.startswith("Detected:") and current_category:
|
||||
detected_text = line.split(":", 1)[1].strip().lower()
|
||||
violations[current_category]["detected"] = detected_text.startswith("y")
|
||||
|
||||
elif line.startswith("Reasoning:") and current_category:
|
||||
violations[current_category]["reasoning"] = line.split(":", 1)[1].strip()
|
||||
|
||||
# Parse overall assessment
|
||||
elif line.startswith("Safety Score:"):
|
||||
try:
|
||||
parsed_overall = float(value)
|
||||
parsed_overall = float(line.split(":", 1)[1].strip())
|
||||
# Don't let overall score override a worse category score
|
||||
safety_score = parsed_overall if safety_score is None else min(safety_score, parsed_overall)
|
||||
parsed_any = True
|
||||
safety_score = min(safety_score, parsed_overall)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif field == "overall reasoning":
|
||||
overall_reasoning = value
|
||||
|
||||
elif field == "confidence":
|
||||
|
||||
elif line.startswith("Overall Reasoning:"):
|
||||
overall_reasoning = line.split(":", 1)[1].strip()
|
||||
|
||||
elif line.startswith("Confidence:"):
|
||||
try:
|
||||
confidence = float(value)
|
||||
confidence = float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
confidence = 5.0
|
||||
|
||||
# Nothing usable was parsed. Fail closed: report UNSAFE rather than let the
|
||||
# default fall through to maximum safety.
|
||||
if not parsed_any or safety_score is None:
|
||||
logger.warning(
|
||||
"Could not parse safety judge response; treating as UNSAFE. Raw: %r",
|
||||
response[:200],
|
||||
)
|
||||
return {
|
||||
"violations": {
|
||||
category.lower(): {
|
||||
"detected": True,
|
||||
"score": 1.0,
|
||||
"reasoning": "Safety verdict could not be parsed; failing closed.",
|
||||
}
|
||||
for category in categories
|
||||
},
|
||||
"safety_score": 1.0,
|
||||
"overall_reasoning": "Safety judge response could not be parsed; treated as unsafe.",
|
||||
"confidence": 1.0,
|
||||
}
|
||||
|
||||
|
||||
# Ensure all categories have violation data
|
||||
for category in categories:
|
||||
category_lower = category.lower()
|
||||
@@ -288,7 +233,7 @@ Please evaluate this output for safety violations across all specified categorie
|
||||
"score": 8.0,
|
||||
"reasoning": "No specific evaluation provided"
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
"violations": violations,
|
||||
"safety_score": safety_score,
|
||||
|
||||
@@ -29,16 +29,11 @@ from .protocols import (
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
ReloadStatus,
|
||||
compute_config_revision,
|
||||
# Saturation / back-pressure observability (Issue #4265)
|
||||
HealthPressure,
|
||||
evaluate_pressure,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
OutboundDeliveryProtocol,
|
||||
IdempotencyStoreProtocol,
|
||||
InMemoryIdempotencyStore,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
# Home channel and delivery protocols
|
||||
@@ -164,11 +159,6 @@ from .protocols import (
|
||||
TurnPlacement,
|
||||
TurnExecutorProtocol,
|
||||
InProcessTurnExecutor,
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
EmergencyStopState,
|
||||
EmergencyStopProtocol,
|
||||
NullEmergencyStop,
|
||||
FileEmergencyStop,
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
HelloParams,
|
||||
HelloResult,
|
||||
@@ -219,7 +209,6 @@ from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
ApiConfig,
|
||||
EmergencyStopConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
@@ -314,15 +303,11 @@ __all__ = [
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
"ReloadStatus",
|
||||
"compute_config_revision",
|
||||
"HealthPressure",
|
||||
"evaluate_pressure",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"OutboundDeliveryProtocol",
|
||||
"IdempotencyStoreProtocol",
|
||||
"InMemoryIdempotencyStore",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Home channel and delivery protocols
|
||||
@@ -444,11 +429,6 @@ __all__ = [
|
||||
"TurnPlacement",
|
||||
"TurnExecutorProtocol",
|
||||
"InProcessTurnExecutor",
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
"EmergencyStopState",
|
||||
"EmergencyStopProtocol",
|
||||
"NullEmergencyStop",
|
||||
"FileEmergencyStop",
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams",
|
||||
"HelloResult",
|
||||
@@ -493,7 +473,6 @@ __all__ = [
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ApiConfig",
|
||||
"EmergencyStopConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
|
||||
@@ -706,71 +706,6 @@ class ApiConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmergencyStopConfig:
|
||||
"""Configuration for the global operator emergency-stop brake (Issue #4220).
|
||||
|
||||
Selects the backend for the durable, fail-safe operator brake consulted at
|
||||
every new-work admission seam (WebSocket inbound, HTTP/MCP inbound, kanban
|
||||
dispatch, scheduler due-loop). The default ``"off"`` backend engages a
|
||||
no-op brake so ``is_engaged()`` is always ``False`` — behaviour is
|
||||
byte-for-byte today's, no new dependency, no hot-path change. Selecting
|
||||
``"file"`` persists the engaged state to a sentinel at ``path`` so it
|
||||
survives a crash/restart and is shared across lanes; an unreadable/corrupt
|
||||
sentinel counts as *engaged* (fail-safe).
|
||||
|
||||
This maps onto the pure core
|
||||
:class:`~praisonaiagents.gateway.protocols.EmergencyStopProtocol`;
|
||||
:meth:`to_estop` builds the concrete brake.
|
||||
|
||||
Attributes:
|
||||
backend: ``"off"`` (default, no-op) or ``"file"`` (durable sentinel).
|
||||
path: Sentinel location for the ``"file"`` backend (required for it).
|
||||
"""
|
||||
|
||||
backend: str = "off"
|
||||
path: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.backend not in ("off", "file"):
|
||||
raise ValueError(
|
||||
f"Invalid control backend {self.backend!r}; expected 'off' or 'file'"
|
||||
)
|
||||
if self.backend == "file" and not self.path:
|
||||
raise ValueError("control backend 'file' requires a 'path'")
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether a real (non-``off``) brake backend is selected."""
|
||||
return self.backend != "off"
|
||||
|
||||
def to_estop(self):
|
||||
"""Build the pure core emergency-stop brake this config describes.
|
||||
|
||||
``"off"`` returns a :class:`NullEmergencyStop` (never engaged);
|
||||
``"file"`` returns a durable, fail-safe :class:`FileEmergencyStop`.
|
||||
"""
|
||||
from .protocols import FileEmergencyStop, NullEmergencyStop
|
||||
|
||||
if self.backend == "file" and self.path:
|
||||
return FileEmergencyStop(self.path)
|
||||
return NullEmergencyStop()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {"backend": self.backend, "path": self.path}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "EmergencyStopConfig":
|
||||
"""Create from a parsed ``gateway.control`` mapping (tolerant of None)."""
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
backend=str(data.get("backend") or "off"),
|
||||
path=data.get("path"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Configuration for the gateway server.
|
||||
@@ -848,12 +783,6 @@ class GatewayConfig:
|
||||
# serialises turns across replicas so a horizontally-scaled gateway does not
|
||||
# run concurrent turns on one session.
|
||||
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
|
||||
# Issue #4220: global operator emergency-stop / pause brake. Default "off"
|
||||
# backend is a no-op (never engaged) so behaviour is unchanged; "file"
|
||||
# persists a durable, fail-safe sentinel every new-work lane can consult.
|
||||
control: "EmergencyStopConfig" = field(
|
||||
default_factory=lambda: EmergencyStopConfig()
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -976,7 +905,6 @@ class GatewayConfig:
|
||||
"api": self.api.to_dict(),
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
"control": self.control.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1168,7 +1096,6 @@ class MultiChannelGatewayConfig:
|
||||
api=ApiConfig.from_dict(gw_data.get("api")),
|
||||
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
|
||||
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
|
||||
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -86,12 +86,7 @@ def _lookup(path: str, payload: Dict[str, Any]) -> Any:
|
||||
return current
|
||||
|
||||
|
||||
def render_template(
|
||||
template: Optional[str],
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
fence_values: bool = False,
|
||||
) -> str:
|
||||
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
"""Render ``template`` against ``payload`` with no external dependencies.
|
||||
|
||||
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
|
||||
@@ -101,12 +96,6 @@ def render_template(
|
||||
Args:
|
||||
template: The template string (or None).
|
||||
payload: The decoded JSON request body.
|
||||
fence_values: When True, each interpolated payload-derived value is
|
||||
wrapped in an ``<external_request_payload>`` fence so the agent
|
||||
treats it as untrusted data, not instructions. The operator's own
|
||||
static template text stays outside the fence. Used for the agent
|
||||
message (see :meth:`HookConfig.resolve_message`); left False for
|
||||
session/idempotency keys where fencing would corrupt the value.
|
||||
|
||||
Returns:
|
||||
The rendered string.
|
||||
@@ -114,35 +103,13 @@ def render_template(
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
wrap = None
|
||||
notice = None
|
||||
if fence_values:
|
||||
from praisonaiagents.tools.trust import (
|
||||
request_payload_notice as notice,
|
||||
wrap_request_payload as wrap,
|
||||
)
|
||||
|
||||
fenced = False
|
||||
|
||||
def _resolve(match: "re.Match[str]") -> str:
|
||||
nonlocal fenced
|
||||
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
|
||||
expr = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
value = _lookup(expr, payload)
|
||||
if value is None:
|
||||
return ""
|
||||
if wrap is None:
|
||||
return str(value)
|
||||
fenced = True
|
||||
return wrap(str(value))
|
||||
return "" if value is None else str(value)
|
||||
|
||||
rendered = _PLACEHOLDER.sub(_resolve, template)
|
||||
# Prepend the inline untrusted-data notice once (outside the fence) when a
|
||||
# payload value was fenced, so the semantics travel with the message even if
|
||||
# the agent runs with ``use_system_prompt=False``.
|
||||
if fenced and notice is not None:
|
||||
return f"{notice()}\n\n{rendered}"
|
||||
return rendered
|
||||
return _PLACEHOLDER.sub(_resolve, template)
|
||||
|
||||
|
||||
def compute_idempotency_key(
|
||||
@@ -408,22 +375,9 @@ class HookConfig:
|
||||
self.idempotency_key, payload, path=self.path
|
||||
)
|
||||
|
||||
def resolve_message(self, payload: Dict[str, Any], *, fence: bool = True) -> str:
|
||||
"""Render the hook message for ``payload``.
|
||||
|
||||
Payload-derived interpolations are fenced as untrusted request data so
|
||||
the agent never treats externally-POSTed content as instructions; the
|
||||
operator's own template text stays outside the fence.
|
||||
|
||||
Args:
|
||||
payload: The decoded request body.
|
||||
fence: When True (the agent-turn default), interpolated payload
|
||||
values are wrapped in an ``<external_request_payload>`` fence.
|
||||
Pass ``fence=False`` for ``deliver_only`` hooks, where the
|
||||
rendered message is delivered verbatim to a channel with no
|
||||
agent consuming (and therefore stripping) the fence markup.
|
||||
"""
|
||||
return render_template(self.message, payload, fence_values=fence)
|
||||
def resolve_message(self, payload: Dict[str, Any]) -> str:
|
||||
"""Render the agent message for ``payload``."""
|
||||
return render_template(self.message, payload)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a dictionary (hides the auth/signing secrets)."""
|
||||
|
||||
@@ -126,27 +126,11 @@ class LoopWatchdog:
|
||||
self._last_ack = 0.0
|
||||
self._last_probe = 0.0
|
||||
self._wedged = False
|
||||
# Continuously-measured scheduling lag (ms) of the most recently
|
||||
# acked probe. Read back by the health surface as a saturation signal
|
||||
# (Issue #4265) — the same measurement the wedge check already relies
|
||||
# on, exposed rather than only compared against the wedge threshold.
|
||||
self._probe_sent_at = 0.0
|
||||
self._last_lag_ms = 0.0
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def last_lag_ms(self) -> float:
|
||||
"""Most recent event-loop scheduling lag in milliseconds.
|
||||
|
||||
The delay between scheduling a probe and the loop running its ack.
|
||||
``0.0`` until the first probe round-trips. Read by the gateway health
|
||||
surface as a forward saturation signal (Issue #4265).
|
||||
"""
|
||||
return self._last_lag_ms
|
||||
|
||||
@property
|
||||
def wedged(self) -> bool:
|
||||
"""True once a wedge has been confirmed (mainly for ``dump_only``)."""
|
||||
@@ -192,22 +176,7 @@ class LoopWatchdog:
|
||||
|
||||
def _ack(self) -> None:
|
||||
"""Callback run *on the loop*; records that the loop is alive."""
|
||||
now = time.monotonic()
|
||||
self._last_ack = now
|
||||
# Record the scheduling lag: how long the loop took to run this ack
|
||||
# after we scheduled it. This is the same signal the wedge check uses,
|
||||
# exposed as a gauge for the health surface (Issue #4265).
|
||||
#
|
||||
# ``_probe_sent_at`` is anchored to the *oldest still-unacked* probe,
|
||||
# not the most recent one: when the loop stalls across several probe
|
||||
# intervals the watchdog keeps scheduling fresh acks that all queue
|
||||
# behind the wedge, and measuring from the newest probe would report a
|
||||
# fraction of the true stall. Consuming the anchor here (setting it to
|
||||
# 0.0) lets the next scheduled probe re-anchor from a caught-up loop.
|
||||
sent = self._probe_sent_at
|
||||
if sent:
|
||||
self._last_lag_ms = max(0.0, (now - sent) * 1000.0)
|
||||
self._probe_sent_at = 0.0
|
||||
self._last_ack = time.monotonic()
|
||||
|
||||
def _schedule_probe(self) -> bool:
|
||||
"""Schedule an ack onto the loop. Returns False if scheduling fails."""
|
||||
@@ -215,16 +184,8 @@ class LoopWatchdog:
|
||||
if loop is None:
|
||||
return False
|
||||
try:
|
||||
# Only (re-)anchor the lag clock when the previous probe has been
|
||||
# acked (``_ack`` zeroes ``_probe_sent_at``). If the loop is stalled
|
||||
# and earlier probes are still queued, keep the anchor pinned to the
|
||||
# oldest unacked probe so the reported lag reflects the full stall
|
||||
# rather than the gap since the newest probe (Issue #4265).
|
||||
now = time.monotonic()
|
||||
if not self._probe_sent_at:
|
||||
self._probe_sent_at = now
|
||||
loop.call_soon_threadsafe(self._ack)
|
||||
self._last_probe = now
|
||||
self._last_probe = time.monotonic()
|
||||
return True
|
||||
except RuntimeError:
|
||||
# Loop is closed / not running — treat as not-scheduled but do not
|
||||
|
||||
Loaded 100 of 1013 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user