mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e1ca6b1fa |
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.
|
||||
@@ -288,7 +287,7 @@ runs:
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,Read,Write,Edit,Bash"
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
@@ -328,7 +327,6 @@ runs:
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$(($INPUT_TIMEOUT_MINUTES * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE" \
|
||||
| tee "$GITHUB_WORKSPACE/.claude-assess-output.jsonl"
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
@@ -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) {
|
||||
|
||||
@@ -213,33 +213,6 @@ assert(
|
||||
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
|
||||
);
|
||||
|
||||
assert(
|
||||
'scan kickoff text is not a verdict',
|
||||
mg.findMergeGateVerdict([
|
||||
{
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
created_at: '2026-06-27T10:05:00Z',
|
||||
},
|
||||
], null, '2026-06-27T09:55:00Z') === null
|
||||
);
|
||||
|
||||
const finalWithFinished = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
|
||||
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T10:05:00Z' },
|
||||
];
|
||||
assert('final complete requires Claude finished reply', mg.finalClaudeCompletedOnSha(finalWithFinished, '2026-06-27T09:55:00Z'));
|
||||
assert('final trigger alone is incomplete', !mg.finalClaudeCompletedOnSha(
|
||||
[{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' }],
|
||||
'2026-06-27T09:55:00Z'
|
||||
));
|
||||
|
||||
assert('recent scan comment detected', mg.hasRecentMergeGateScanComment([
|
||||
{ body: '**Merge gate scan** — eligible', created_at: new Date().toISOString() },
|
||||
]));
|
||||
assert('old scan comment not recent', !mg.hasRecentMergeGateScanComment([
|
||||
{ body: '**Merge gate scan** — eligible', created_at: '2020-01-01T00:00:00Z' },
|
||||
]));
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
@@ -284,13 +257,8 @@ const finalAfterRebase = {
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: conflictIso(-20 * 60 * 1000),
|
||||
};
|
||||
const claudeFinishedAfterRebase = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Claude finished',
|
||||
created_at: conflictIso(-19 * 60 * 1000),
|
||||
};
|
||||
const headAfterRebase = conflictIso(-21 * 60 * 1000);
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase, claudeFinishedAfterRebase];
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
|
||||
@@ -304,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);
|
||||
@@ -329,54 +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);
|
||||
assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
|
||||
|
||||
(async () => {
|
||||
const skipLow = await mg.shouldSkipMergeGateDispatch(
|
||||
{
|
||||
rest: {
|
||||
rateLimit: {
|
||||
get: async () => ({ data: { resources: { core: { remaining: 50 } } } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
'owner',
|
||||
'repo',
|
||||
1,
|
||||
{ info: () => {} }
|
||||
);
|
||||
assert('rate limit low skips dispatch', skipLow === true);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.exit(failed ? 1 : 0);
|
||||
+11
-147
@@ -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}/,
|
||||
@@ -315,20 +304,11 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
const finals = comments.filter(isFinalClaudeTriggerComment);
|
||||
if (finals.length === 0) return false;
|
||||
const latestFinal = finals.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const finalTime = new Date(latestFinal.created_at).getTime();
|
||||
return comments.some((c) => {
|
||||
if (!isClaudeFinalReplyComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() >= finalTime - 60000;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
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 = `
|
||||
@@ -484,79 +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;
|
||||
const MIN_CORE_RATE_LIMIT_REMAINING = 200;
|
||||
|
||||
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 getCoreRateLimitRemaining(github) {
|
||||
try {
|
||||
const { data } = await github.rest.rateLimit.get();
|
||||
return data?.resources?.core?.remaining ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
|
||||
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
|
||||
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
|
||||
const remaining = await getCoreRateLimitRemaining(github);
|
||||
if (remaining !== null && remaining < minCoreRemaining) {
|
||||
core?.info?.(
|
||||
`Skip merge gate dispatch for PR #${prNumber}: rate limit low (${remaining} core remaining)`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
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({
|
||||
@@ -720,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;
|
||||
}
|
||||
|
||||
@@ -1029,33 +919,18 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
|
||||
}
|
||||
|
||||
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
|
||||
const MERGE_GATE_SCAN_MARKER = 'Merge gate scan';
|
||||
|
||||
function isAutomatedFallbackVerdict(body) {
|
||||
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
|
||||
}
|
||||
|
||||
function isMergeGateVerdictComment(body) {
|
||||
const text = body || '';
|
||||
if (text.includes(MERGE_GATE_SCAN_MARKER)) return false;
|
||||
return /(?:^|\n)\s*MERGE_GATE_VERDICT:\s*(APPROVE|BLOCK)\b/m.test(text);
|
||||
}
|
||||
|
||||
function hasRecentMergeGateScanComment(comments, withinMinutes = 30) {
|
||||
const cutoff = Date.now() - withinMinutes * 60 * 1000;
|
||||
return (comments || []).some((c) => {
|
||||
if (!(c.body || '').includes(MERGE_GATE_SCAN_MARKER)) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
|
||||
const { excludeAutomatedFallback = false } = options || {};
|
||||
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
|
||||
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
|
||||
const gateComments = comments
|
||||
.filter((c) => {
|
||||
if (!isMergeGateVerdictComment(c.body)) return false;
|
||||
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
@@ -1065,8 +940,8 @@ function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
if (gateComments.length === 0) return null;
|
||||
const body = gateComments[0].body || '';
|
||||
if (/MERGE_GATE_VERDICT:\s*APPROVE\b/.test(body)) return 'APPROVE';
|
||||
if (/MERGE_GATE_VERDICT:\s*BLOCK\b/.test(body)) return 'BLOCK';
|
||||
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1137,16 +1012,5 @@ module.exports = {
|
||||
selectMergeGateCandidates,
|
||||
isAutomatedFallbackVerdict,
|
||||
AUTOMATED_FALLBACK_MARKER,
|
||||
isMergeGateVerdictComment,
|
||||
hasRecentMergeGateScanComment,
|
||||
findMergeGateVerdict,
|
||||
MERGE_GATE_WORKFLOW_FILE,
|
||||
MERGE_GATE_ACTIVE_LABEL,
|
||||
MAX_PENDING_MERGE_GATE_RUNS,
|
||||
MIN_CORE_RATE_LIMIT_REMAINING,
|
||||
getCoreRateLimitRemaining,
|
||||
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,
|
||||
@@ -201,26 +196,17 @@ jobs:
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, skipped[0].pr);
|
||||
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
const prNum = candidates[0].pr_number;
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNum);
|
||||
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNum,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge on an APPROVE verdict.',
|
||||
});
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(
|
||||
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
|
||||
);
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
@@ -276,7 +262,6 @@ jobs:
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -303,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).
|
||||
@@ -318,97 +303,15 @@ jobs:
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
mcp__github__add_issue_comment
|
||||
mcp__github__get_pull_request
|
||||
mcp__github__get_pull_request_files
|
||||
mcp__github__get_pull_request_reviews
|
||||
mcp__github__get_pull_request_status
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
Glob
|
||||
Grep
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post Opus verdict from assess output if MCP missed
|
||||
id: relay_verdict
|
||||
if: always() && steps.assess.conclusion != 'skipped'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already on PR: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outPath = `${process.env.GITHUB_WORKSPACE}/.claude-assess-output.jsonl`;
|
||||
if (!fs.existsSync(outPath)) {
|
||||
core.info('No assess output file to relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
|
||||
let verdict = null;
|
||||
let rationale = '';
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line.includes('MERGE_GATE_VERDICT: APPROVE')) {
|
||||
verdict = 'APPROVE';
|
||||
rationale = line;
|
||||
break;
|
||||
}
|
||||
if (line.includes('MERGE_GATE_VERDICT: BLOCK')) {
|
||||
verdict = 'BLOCK';
|
||||
rationale = line;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const evt = JSON.parse(line);
|
||||
const text = JSON.stringify(evt);
|
||||
if (text.includes('MERGE_GATE_VERDICT: APPROVE')) {
|
||||
verdict = 'APPROVE';
|
||||
rationale = text;
|
||||
break;
|
||||
}
|
||||
if (text.includes('MERGE_GATE_VERDICT: BLOCK')) {
|
||||
verdict = 'BLOCK';
|
||||
rationale = text;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — keep scanning.
|
||||
}
|
||||
}
|
||||
if (!verdict) {
|
||||
core.info('Assess output had no MERGE_GATE_VERDICT to relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Relayed from Opus merge gate assess output (GitHub MCP comment post was unavailable).',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body,
|
||||
});
|
||||
core.info(`Relayed Opus verdict to PR comment: ${verdict}`);
|
||||
|
||||
- name: Post fallback verdict if Opus did not comment
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: always() && steps.assess.conclusion != 'skipped'
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -424,21 +327,20 @@ jobs:
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
|
||||
const lines = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate did not post a PR comment.',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
@@ -446,10 +348,9 @@ jobs:
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Capture Opus MERGE_GATE_VERDICT
|
||||
id: capture_verdict
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -465,14 +366,14 @@ jobs:
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) {
|
||||
core.warning(
|
||||
'Opus MERGE_GATE_VERDICT not posted on PR (GitHub MCP tools may be blocked). Merge waits for APPROVE.'
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
} else {
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
@@ -493,6 +394,10 @@ jobs:
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
|
||||
@@ -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,267 +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', ''
|
||||
$core = ($expected -split '[-+]')[0]
|
||||
$exe = "target/${{ matrix.target }}/release/praisonai-desktop.exe"
|
||||
$version = (Get-Item $exe).VersionInfo
|
||||
Write-Host "ProductVersion=$($version.ProductVersion) FileVersion=$($version.FileVersion)"
|
||||
|
||||
if ($version.ProductVersion -ne $expected) {
|
||||
throw "ProductVersion $($version.ProductVersion) does not match $expected"
|
||||
}
|
||||
|
||||
# Compare the three components that carry meaning, not the string.
|
||||
#
|
||||
# This gate blocked the v4.7.4 Windows build with
|
||||
# FileVersion 4.7.4 does not match 4.7.4.0
|
||||
# It appended ".0" and compared literally, but Windows reports what
|
||||
# was stamped -- three parts here. The stamping was correct and the
|
||||
# check was not, so the release shipped with no Windows installer at
|
||||
# all: the one platform it was cut to fix.
|
||||
#
|
||||
# [version] normalises a missing fourth component to -1, so both
|
||||
# "4.7.4" and "4.7.4.0" compare equal on major.minor.build.
|
||||
$got = [version]$version.FileVersion
|
||||
$want = [version]$core
|
||||
if ($got.Major -ne $want.Major -or
|
||||
$got.Minor -ne $want.Minor -or
|
||||
$got.Build -ne $want.Build) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $core"
|
||||
}
|
||||
|
||||
- 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,351 +0,0 @@
|
||||
# The mobile package's PLATFORM builds: a real iOS app and a real Android app.
|
||||
#
|
||||
# mobile.yml proves the crate type-checks for a phone (`cargo check` against
|
||||
# the two mobile targets). This proves the two generated platform projects,
|
||||
# src-tauri/gen/apple and src-tauri/gen/android, still turn that crate into an
|
||||
# installable app -- which is a different thing, and the thing that had never
|
||||
# happened: until #4673 neither project existed.
|
||||
#
|
||||
# What each job produces, and why that shape:
|
||||
#
|
||||
# ios-simulator -- an UNSIGNED arm64 simulator .app. A device build needs
|
||||
# an Apple Developer Team and a signing identity, which are
|
||||
# the maintainer's and not in this repo (bundle.iOS.
|
||||
# developmentTeam is unset in tauri.conf.json). A simulator
|
||||
# build needs neither, so it is the build that can run on
|
||||
# every PR. It is not decoration: it links the Rust
|
||||
# staticlib into the Xcode target, packages the webview
|
||||
# into the bundle and exercises Info.plist, i.e. every step
|
||||
# short of the certificate.
|
||||
# android-apk -- a debug APK, arm64-v8a. Debug because a release APK
|
||||
# needs a signing keystore, which is likewise the
|
||||
# maintainer's; the debug keystore is generated on the
|
||||
# runner. It installs on any arm64 Android 8.0+ device with
|
||||
# `adb install`.
|
||||
#
|
||||
# Both jobs fail if the thing they exist to produce is missing. The bundler
|
||||
# exits zero on a target it silently skipped (desktop-release.yml learned this
|
||||
# the hard way), so "the step passed" is not evidence; "the file exists and
|
||||
# contains the Rust library" is.
|
||||
#
|
||||
# The Xcode project is generated on the macOS runner the first time (this
|
||||
# laptop has no Xcode, only the Command Line Tools) and then committed, so
|
||||
# the init step is a no-op once gen/apple is in the tree. The Android project
|
||||
# was generated locally and is committed; the same guard keeps that job
|
||||
# honest if someone deletes it.
|
||||
name: mobile platforms
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- '.github/workflows/mobile-platforms.yml'
|
||||
# Without this, no platform build had ever run on main -- so a break
|
||||
# introduced by two PRs that were each green apart could sit on main
|
||||
# unnoticed. That is exactly what happened: #4737 branched from before
|
||||
# #4727 added the `file:` dependency, so the two were never installed
|
||||
# together until they had both landed.
|
||||
push:
|
||||
branches: [main]
|
||||
paths: *watched
|
||||
|
||||
concurrency:
|
||||
# Per-ref on a PR, per-SHA on main. This is the crux of the fix, not
|
||||
# cancel-in-progress alone: a concurrency group holds at most ONE
|
||||
# running run and ONE pending run, and a newer arrival always replaces
|
||||
# the pending one -- cancel-in-progress: false spares only the RUNNING
|
||||
# run, never the queued one. So with a single mobile-platforms-main
|
||||
# group, three merges in one ~3-minute window would still drop the
|
||||
# middle commit's run (it is only ever pending), which is precisely the
|
||||
# blindness the push trigger was added to remove. Giving each main SHA
|
||||
# its own group makes every commit a candidate release with its own
|
||||
# answer, with nothing to contend for and so nothing to cancel.
|
||||
group: mobile-platforms-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
|
||||
# On a PR, superseding an in-flight build is right: only the tip
|
||||
# matters and these jobs cost ~3 minutes on two runners. On main each
|
||||
# group has a single run, so this is a no-op there -- left true because
|
||||
# the condition that mattered is now expressed in the group name above,
|
||||
# and this keeps the PR behaviour explicit.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile
|
||||
|
||||
jobs:
|
||||
ios-simulator:
|
||||
name: ios simulator (.app, unsigned)
|
||||
runs-on: macos-15
|
||||
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:
|
||||
# Both: `tauri ios init` installs whatever it thinks is missing via
|
||||
# rustup, and having them already present keeps that step quiet and
|
||||
# deterministic. aarch64-apple-ios-sim is the one the build below
|
||||
# actually links against.
|
||||
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ios-sim
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
|
||||
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
|
||||
# fresh runner praisonai-ts has no node_modules, so rimraf is not
|
||||
# installed and `npm ci` dies with code 127 before a single
|
||||
# platform step runs. mobile.yml gives all four of its jobs this
|
||||
# step; these two were written against the tree from before the
|
||||
# dependency existed and never got it.
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
|
||||
- name: Generate the Xcode project (only if gen/apple is not committed)
|
||||
# `tauri ios init` renders the template and then runs `xcodegen`, a
|
||||
# brew package the CLI would otherwise try to install itself, mid-run,
|
||||
# with a prompt. Installing it up front is the same outcome without
|
||||
# the prompt. cocoapods is only invoked when the config declares pods;
|
||||
# it declares none.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d src-tauri/gen/apple ]; then
|
||||
echo "src-tauri/gen/apple is committed; not regenerating it"
|
||||
exit 0
|
||||
fi
|
||||
brew list xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
npx tauri ios init --ci --skip-targets-install
|
||||
|
||||
- name: Put the real AppIcon set into the Xcode project
|
||||
# `tauri ios init` renders the CLI's OWN placeholder AppIcon PNGs into
|
||||
# gen/apple/Assets.xcassets, and nothing in the CLI ever replaces them:
|
||||
# `tauri icon` writes to src-tauri/icons/ios/ and stops there (it wires
|
||||
# up gen/android, but there is no iOS equivalent). So the copy is done
|
||||
# here, and every filename matches one-for-one -- both sides are named
|
||||
# by the same Contents.json.
|
||||
#
|
||||
# Idempotent, and it runs whether or not init just ran, so a
|
||||
# regenerated project cannot silently ship Tauri's icon as ours.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dest=src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset
|
||||
test -d "$dest" || { echo "::error::$dest does not exist"; find src-tauri/gen/apple -maxdepth 3 -print; exit 1; }
|
||||
cp src-tauri/icons/ios/*.png "$dest/"
|
||||
ls "$dest" | wc -l
|
||||
|
||||
- name: Build the simulator app, unsigned
|
||||
# --target aarch64-sim: the iphonesimulator SDK, arm64 (the runner is
|
||||
# Apple silicon, and so is every Mac that will run the simulator).
|
||||
# --no-sign: xcodebuild with CODE_SIGNING_ALLOWED=NO. For the
|
||||
# simulator SDK the CLI then lifts PraisonAI.app straight out of the
|
||||
# xcarchive instead of exporting an IPA (crates/tauri-cli/src/mobile/
|
||||
# ios/build.rs, the `target.sdk == "iphonesimulator"` branch), which
|
||||
# is exactly the artifact a simulator installs.
|
||||
# --debug: the dev profile links in seconds rather than minutes, and a
|
||||
# simulator build is never the one that ships.
|
||||
run: npx tauri ios build --debug --target aarch64-sim --no-sign --ci
|
||||
|
||||
- name: Is there an app, and is the Rust inside it?
|
||||
id: app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root=src-tauri/gen/apple/build
|
||||
# Printed before anything is asserted, so a failure here is
|
||||
# diagnosable from the log alone rather than costing another run.
|
||||
echo "--- what the build left behind"
|
||||
find "$root" -maxdepth 3 -print 2>/dev/null | head -40 || echo "(no build directory)"
|
||||
|
||||
# Prefer the simulator export dir (build/arm64-sim, the `arch` of the
|
||||
# aarch64-sim target); fall back to any .app so the error message can
|
||||
# say what WAS built. The name is not hardcoded: it comes from
|
||||
# productName via the CLI, and guessing it wrong is what failed here
|
||||
# the first time.
|
||||
app="$(find "$root" -type d -name '*.app' -path '*arm64-sim*' -print -quit 2>/dev/null || true)"
|
||||
[ -n "$app" ] || app="$(find "$root" -type d -name '*.app' -print -quit 2>/dev/null || true)"
|
||||
if [ -z "$app" ]; then
|
||||
echo "::error::no .app was produced under $root"
|
||||
exit 1
|
||||
fi
|
||||
echo "app: $app"
|
||||
|
||||
# The executable's name comes from the bundle itself for the same
|
||||
# reason.
|
||||
exe="$app/$(plutil -extract CFBundleExecutable raw "$app/Info.plist")"
|
||||
test -f "$exe" || { echo "::error::$app has no executable"; ls -la "$app"; exit 1; }
|
||||
file "$exe"
|
||||
file "$exe" | grep -q 'arm64' || { echo "::error::the executable is not arm64"; exit 1; }
|
||||
|
||||
# iOS links the Rust in as a staticlib, so unlike Android there is no
|
||||
# .so to look for -- the crate is INSIDE this binary, and its symbols
|
||||
# are the only real evidence it got there. An .app that built without
|
||||
# it would be the Swift template alone.
|
||||
#
|
||||
# Dumped to files rather than piped into `grep -q`: grep exits on the
|
||||
# FIRST match, the dumper then dies of SIGPIPE, and `set -o pipefail`
|
||||
# reports that as a failed pipeline -- so the check failed precisely
|
||||
# BECAUSE it found what it was looking for. It cost a CI run.
|
||||
nm -a "$exe" > "$RUNNER_TEMP/symbols.txt" 2>/dev/null || true
|
||||
strings -a "$exe" > "$RUNNER_TEMP/strings.txt" 2>/dev/null || true
|
||||
if grep -qi 'praisonai_mobile\|start_app' "$RUNNER_TEMP/symbols.txt" \
|
||||
|| grep -qi 'praisonai_mobile' "$RUNNER_TEMP/strings.txt"; then
|
||||
echo "the Rust crate is linked into the executable"
|
||||
else
|
||||
echo "::error::the Rust library is not linked into $exe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The frontend is embedded in that same binary by generate_context!,
|
||||
# not copied into the bundle as a resource, so it is checked the same
|
||||
# way rather than by looking for an index.html that is never there.
|
||||
plutil -p "$app/Info.plist" | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|MinimumOSVersion|UIDeviceFamily'
|
||||
test -f "$app/Assets.car" || echo "note: no compiled asset catalog in the bundle"
|
||||
du -sh "$app"
|
||||
|
||||
# ditto, not zip: it keeps the bundle's symlinks and modes, which is
|
||||
# the difference between an .app the simulator installs and one it
|
||||
# rejects as an invalid bundle.
|
||||
ditto -c -k --keepParent "$app" "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
|
||||
ls -la "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
|
||||
|
||||
- name: Upload the simulator app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PraisonAI-ios-simulator-app
|
||||
path: ${{ runner.temp }}/PraisonAI-ios-simulator.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Package gen/apple for committing
|
||||
# always(): the project is generated by `init`, so it is worth having
|
||||
# even on a run whose BUILD failed -- that is exactly the run where
|
||||
# someone needs to look at it.
|
||||
if: always()
|
||||
# Only what `git add src-tauri/gen/apple` would take: the template's
|
||||
# own .gitignore excludes build/, xcuserdata/ and Externals/, and this
|
||||
# respects it instead of re-listing them here. Nothing to upload when
|
||||
# the project was already committed -- the guard above skipped init.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
files="$(git ls-files --others --exclude-standard src-tauri/gen/apple || true)"
|
||||
if [ -z "$files" ]; then
|
||||
echo "gen/apple is tracked already; nothing to package"
|
||||
exit 0
|
||||
fi
|
||||
echo "$files" | tar -czf "$RUNNER_TEMP/gen-apple.tgz" -T -
|
||||
echo "$files" | wc -l
|
||||
ls -la "$RUNNER_TEMP/gen-apple.tgz"
|
||||
|
||||
- name: Upload gen/apple (only on the run that generated it)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gen-apple
|
||||
path: ${{ runner.temp }}/gen-apple.tgz
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
android-apk:
|
||||
name: android apk (debug, arm64)
|
||||
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
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
# 17, not the runner's default 21 or a laptop's 25: Gradle 8.14.3's
|
||||
# Groovy front end rejects class files newer than it knows
|
||||
# ("Unsupported class file major version 69" on JDK 25), and 17 is
|
||||
# what AGP 8.11 documents.
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# What gen/android/app/build.gradle.kts declares (compileSdk 36),
|
||||
# plus the build-tools AGP pairs with it. Anything else AGP needs it
|
||||
# downloads itself; the licences are accepted by this action.
|
||||
packages: 'platform-tools platforms;android-36 build-tools;36.0.0'
|
||||
- uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
# r28c = 28.2.13676358, the NDK gen/android was generated and first
|
||||
# built with.
|
||||
ndk-version: r28c
|
||||
add-to-path: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: android-apk
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
|
||||
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
|
||||
# fresh runner praisonai-ts has no node_modules, so rimraf is not
|
||||
# installed and `npm ci` dies with code 127 before a single
|
||||
# platform step runs. mobile.yml gives all four of its jobs this
|
||||
# step; these two were written against the tree from before the
|
||||
# dependency existed and never got it.
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
|
||||
- name: Generate the Android project (only if gen/android is not committed)
|
||||
env:
|
||||
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d src-tauri/gen/android ]; then
|
||||
echo "src-tauri/gen/android is committed; not regenerating it"
|
||||
exit 0
|
||||
fi
|
||||
npx tauri android init --ci --skip-targets-install
|
||||
|
||||
- name: Build the debug APK
|
||||
env:
|
||||
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
# --target aarch64 only: every phone sold in the last several years is
|
||||
# arm64-v8a, and one target keeps the Rust build to one link. The
|
||||
# `universal` in the output path is Gradle's name for "the APK that is
|
||||
# not split per ABI", not a claim about what is inside it.
|
||||
run: npx tauri android build --debug --apk --target aarch64 --ci
|
||||
|
||||
- name: Is there an APK, and is the Rust inside it?
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root=src-tauri/gen/android/app/build/outputs/apk
|
||||
apk="$(find "$root" -name '*.apk' -print -quit || true)"
|
||||
if [ -z "$apk" ]; then
|
||||
echo "::error::no .apk was produced under $root"
|
||||
find "$root" -type f -print 2>/dev/null || echo "(the outputs directory does not exist)"
|
||||
exit 1
|
||||
fi
|
||||
echo "apk: $apk"
|
||||
ls -la "$apk"
|
||||
unzip -l "$apk" | grep -q 'lib/arm64-v8a/libpraisonai_mobile_lib.so' \
|
||||
|| { echo "::error::$apk does not contain lib/arm64-v8a/libpraisonai_mobile_lib.so"; unzip -l "$apk" | head -40; exit 1; }
|
||||
unzip -l "$apk" | grep -E 'lib/|AndroidManifest|classes.dex' | head
|
||||
cp "$apk" "$RUNNER_TEMP/PraisonAI-android-debug-arm64.apk"
|
||||
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PraisonAI-android-debug-arm64-apk
|
||||
path: ${{ runner.temp }}/PraisonAI-android-debug-arm64.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -1,164 +0,0 @@
|
||||
# The mobile package's WEB build: built, booted in a real browser, and
|
||||
# deployed to GitHub Pages.
|
||||
#
|
||||
# mobile.yml proves the package's tests pass. This workflow proves the thing
|
||||
# that ships to a browser actually ships: dist/ is built, served under the
|
||||
# repository subpath and driven through first paint, install, and an offline
|
||||
# reload by tools/web-boot.test.mjs, then published. A PR gets the build and
|
||||
# the proof; only main deploys.
|
||||
#
|
||||
# One setting is outside this file's reach: the repository's Pages source must
|
||||
# be "GitHub Actions" (Settings -> Pages -> Build and deployment -> Source).
|
||||
# Until a human turns that on, the deploy job SKIPS with a notice rather than
|
||||
# failing -- a red main every push because of a setting no workflow can set
|
||||
# would train people to ignore the mobile checks, which is how the last silent
|
||||
# breakage in this package survived.
|
||||
name: mobile web
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- '.github/workflows/mobile-web.yml'
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts, and the web bundle is
|
||||
# built FROM it -- so drift on that side changes what ships to a browser
|
||||
# and must trigger this gate. mobile.yml watches these same paths for the
|
||||
# same reason; a web gate that watched only praisonai-mobile would go
|
||||
# quiet exactly when the dependency it bundles moved.
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/scripts/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
pull_request:
|
||||
paths: *watched
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and boot
|
||||
runs-on: ubuntu-22.04
|
||||
concurrency:
|
||||
group: mobile-web-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
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
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts, so its dist/ must
|
||||
# exist before this package can even INSTALL: npm runs the linked
|
||||
# package's prepare/prebuild, which fails without its own node_modules.
|
||||
# `npm install`, not `npm ci`: praisonai-ts gitignores its lockfile.
|
||||
# `--legacy-peer-deps` for the bedrock-agentcore -> @strands-agents/sdk
|
||||
# peer conflict, exactly as mobile.yml and praisonai-ts-webview.yml do.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
- name: Cache the Playwright browser
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
|
||||
- name: Install Chromium for the boot proof
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Boot proof
|
||||
# Serves dist/ under /PraisonAI/ in headless Chromium: composer and
|
||||
# Send render, manifest served as application/manifest+json, service
|
||||
# worker activates with the subpath as scope, an offline reload still
|
||||
# renders, and an injected inline script is blocked by the CSP.
|
||||
run: npm run test:web
|
||||
- name: dist contents
|
||||
run: ls -lR dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mobile-web-dist
|
||||
path: src/praisonai-mobile/dist
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: src/praisonai-mobile/dist
|
||||
|
||||
# The readiness check is its OWN job, and deliberately declares no
|
||||
# `environment:`. A job that names `environment: github-pages` cannot START
|
||||
# until that environment exists, so when Pages is off the runner fails the
|
||||
# job outright -- zero steps, about one second -- and every carefully
|
||||
# written skip inside it is unreachable. Measured: run 33730322658 on main,
|
||||
# "deploy to GitHub Pages" failed with steps: 0.
|
||||
#
|
||||
# Splitting the decision out means the check runs with no preconditions and
|
||||
# the environment-bearing job is never scheduled unless it can succeed.
|
||||
pages-ready:
|
||||
name: is GitHub Pages enabled?
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
# `gh api repos/<owner>/<repo>/pages` needs Pages read. Without it the
|
||||
# request is rejected on a private repo, `2>/dev/null || echo '{}'`
|
||||
# swallows the error to `{}`, build_type falls to "none", and the deploy
|
||||
# SKIPS even though Pages is on -- the exact silent skip this file exists
|
||||
# to prevent. `contents: read` alone leaves this permission at `none`.
|
||||
pages: read
|
||||
outputs:
|
||||
ready: ${{ steps.check.outputs.ready }}
|
||||
steps:
|
||||
# Pages cannot be enabled from a workflow, so this reports the setting
|
||||
# rather than demanding it. `ready` false SKIPS the deploy with a
|
||||
# notice; it does not fail the run. deploy-pages would otherwise fail
|
||||
# with a bare 404 that says nothing about which setting is missing.
|
||||
- name: Is the Pages source "GitHub Actions"?
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
working-directory: .
|
||||
run: |
|
||||
site=$(gh api "repos/${{ github.repository }}/pages" 2>/dev/null || echo '{}')
|
||||
build_type=$(echo "$site" | jq -r '.build_type // "none"')
|
||||
cname=$(echo "$site" | jq -r '.cname // ""')
|
||||
echo "build_type=$build_type cname=${cname:-<none>}"
|
||||
if [ "$build_type" != "workflow" ]; then
|
||||
echo "::notice::Skipping the Pages deploy: Pages is not set to build from GitHub Actions (build_type=$build_type). A repository admin must set it at https://github.com/${{ github.repository }}/settings/pages -> Build and deployment -> Source: GitHub Actions. The built site is attached to this run as the mobile-web-dist artifact."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$cname" ]; then
|
||||
echo "::notice::Skipping the Pages deploy: a custom domain ($cname) is set, so the app would be served there rather than at the github.io URL."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
deploy:
|
||||
name: deploy to GitHub Pages
|
||||
needs: pages-ready
|
||||
if: needs.pages-ready.outputs.ready == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
concurrency:
|
||||
# Never cancel a deployment in flight; queue behind it instead.
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
@@ -1,263 +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: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- '.github/workflows/mobile.yml'
|
||||
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
|
||||
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
|
||||
# Watching only praisonai-mobile made that gate fire when the CONSUMER
|
||||
# changed and stay silent when the thing it watches did -- so upstream
|
||||
# drift landed on main unchecked. Verified on 71197fa80, which edited
|
||||
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
|
||||
- 'src/praisonai-ts/src/**'
|
||||
# And the pieces of praisonai-ts that decide what `praisonai/mobile`
|
||||
# RESOLVES to through the file: link -- the esm shim shapes dist/esm.
|
||||
- 'src/praisonai-ts/scripts/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
pull_request:
|
||||
paths: *watched
|
||||
|
||||
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
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
- name: Cache the Playwright browser
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
|
||||
- name: Install Chromium for the boot proof
|
||||
# `npm test` includes tools/web-boot.test.mjs, which loads the built
|
||||
# dist/ in a real browser. It fails rather than skips without one.
|
||||
run: npx playwright install --with-deps chromium
|
||||
- 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
|
||||
# not needed here (the `check` job installs them, with --legacy-peer-deps,
|
||||
# to build the file: dependency): 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
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- 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.
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The mobile targets, which nothing compiled.
|
||||
#
|
||||
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
|
||||
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
|
||||
# ios targets. Both legs of `rust shell` above are HOST targets, so that
|
||||
# attribute -- the one that generates the Android JNI symbol and the iOS
|
||||
# start_app symbol -- expanded exactly zero times in CI, and neither did the
|
||||
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
|
||||
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
|
||||
# these.
|
||||
#
|
||||
# `cargo check`, not `build`: it type-checks and macro-expands under the
|
||||
# mobile cfg, which is the thing that was never done, and needs no NDK and no
|
||||
# Xcode project. Verified at 18s for the android target.
|
||||
# ------------------------------------------------------------------
|
||||
cross-check:
|
||||
name: cross-check (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
os: ubuntu-22.04
|
||||
- target: aarch64-apple-ios
|
||||
os: 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:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# generate_context! expands frontendDist at COMPILE time and dist/ is
|
||||
# gitignored, so the crate cannot be checked without this -- the same
|
||||
# reason the job above needs it.
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Does the crate still expand and type-check for a phone?
|
||||
run: cargo check --lib --target ${{ matrix.target }}
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Parity Gate
|
||||
|
||||
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
|
||||
#
|
||||
# Two named checks, so each kind of drift is visible in the checks list:
|
||||
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
|
||||
# must be regenerated in the PR when the public surface changes.
|
||||
# behaviour - options accepted for parity but not yet acted on; a ratchet,
|
||||
# so the count may fall and never rise.
|
||||
# signatures - constructor/method parameters of the curated surfaces in
|
||||
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
|
||||
# match (after camelCase/alias/flattening rules) or be waived with a
|
||||
# reason in waivers.yaml. Stale or expired waivers also fail.
|
||||
#
|
||||
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
|
||||
# This workflow never commits; it only reports.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**'
|
||||
- 'src/praisonai/praisonai/cli/features/**'
|
||||
# The generator the `names` job runs. Editing it changed what the gate
|
||||
# measured without the gate running.
|
||||
- 'src/praisonai/scripts/**'
|
||||
- 'src/praisonai-ts/PARITY.md'
|
||||
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
|
||||
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
|
||||
- 'src/praisonai-ts/signature-parity.json'
|
||||
# The behaviour baseline itself. These two carry the ratchet floor, so a
|
||||
# pull request that edited only them raised the floor with no job running.
|
||||
- 'src/praisonai-ts/BEHAVIOUR_PARITY.md'
|
||||
- 'src/praisonai-ts/behaviour-parity.json'
|
||||
- 'src/praisonai-rust/PARITY.md'
|
||||
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
|
||||
- '.github/workflows/parity-gate.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
names:
|
||||
name: names (exported-symbol tracker up to date)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pyyaml
|
||||
- name: Tracker files match the sources
|
||||
run: |
|
||||
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
|
||||
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signatures:
|
||||
name: signatures (constructor and method parameters)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: pip install pyyaml
|
||||
- name: Install the TypeScript compiler only
|
||||
# praisonai-ts's full dependency tree is not needed (and does not
|
||||
# install cleanly); the extractor needs only the `typescript` package.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/parity-ts"
|
||||
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
|
||||
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
|
||||
- name: Signatures match, or are waived with a reason
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
if ! python3 -m praisonai._dev.parity.signatures --check; then
|
||||
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
behaviour:
|
||||
name: behaviour (options accepted but not acted on)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pyyaml
|
||||
- name: The count of ignored options has not grown
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
if ! python3 -m praisonai._dev.parity.behaviour --check; then
|
||||
echo "::error::An option accepted for Python parity is not acted on, and the total grew. The name and signature gates cannot see this: they check that a parameter EXISTS. Implement the behaviour and delete its entry from src/praisonai-ts/src/utils/parity-notice.ts, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.behaviour --write"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,70 +0,0 @@
|
||||
# Does praisonai-ts still pass its own tests?
|
||||
#
|
||||
# It has a suite -- 2,000+ tests -- and until now nothing ran it. The only
|
||||
# workflow with `working-directory: src/praisonai-ts` builds the package and
|
||||
# checks the webview bundle; the two other mentions of `npm test` in this
|
||||
# directory are a comment and a line of prose telling a reviewer to run it by
|
||||
# hand. So the suite was green the way an unread report is green.
|
||||
#
|
||||
# That mattered the moment the suite grew: PR #4755 added ~880 tests for the
|
||||
# Python-parity port and not one of them would have run here. A test nobody
|
||||
# executes is worse than no test, because it is counted as cover.
|
||||
#
|
||||
# Deliberately narrow, like its webview sibling: it runs only when
|
||||
# praisonai-ts changes, installs only what it needs, and asserts two things --
|
||||
# the package typechecks, and its tests pass.
|
||||
name: praisonai-ts tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/tests/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/jest.config.ts'
|
||||
- 'src/praisonai-ts/tsconfig*.json'
|
||||
- '.github/workflows/praisonai-ts-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/tests/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/jest.config.ts'
|
||||
- 'src/praisonai-ts/tsconfig*.json'
|
||||
- '.github/workflows/praisonai-ts-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: praisonai-ts-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: unit tests
|
||||
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 --legacy-peer-deps`, matching praisonai-ts-webview.yml:
|
||||
# the lockfile is gitignored so `npm ci` has nothing to read, and the
|
||||
# dependency tree does not resolve without the flag (bedrock-agentcore
|
||||
# wants a peer of @strands-agents/sdk that is not installed).
|
||||
- name: Install
|
||||
run: npm install --no-audit --no-fund --legacy-peer-deps
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit -p .
|
||||
# PRAISONAI_PARITY_SILENT quiets the one-line notices that options
|
||||
# accepted for Python parity print when they are not yet honoured; they
|
||||
# are deliberate output, not failures, and they drown the test log.
|
||||
- name: Tests
|
||||
run: npm test
|
||||
env:
|
||||
PRAISONAI_PARITY_SILENT: '1'
|
||||
@@ -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
|
||||
|
||||
@@ -759,126 +759,6 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'false'
|
||||
run: echo "✅ Cohere tests skipped (no API key available)"
|
||||
|
||||
# ============================================
|
||||
# OLLAMA LIVE TESTS - Real local model, no API key
|
||||
# Nightly / extended only. Installs Ollama on the runner and pulls a small
|
||||
# tools-capable model. Unlike the other live jobs this one needs no secret,
|
||||
# so its required step has no "|| echo" fallback: a failure here is a real
|
||||
# regression in local-model tool calling, not a missing credential.
|
||||
# ============================================
|
||||
extended-ollama:
|
||||
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Bump deliberately: this is the version everything in this job was
|
||||
# verified against. Latest at time of writing is v0.33.3.
|
||||
OLLAMA_VERSION: v0.33.2
|
||||
# 522 MB, capabilities: completion, tools, thinking.
|
||||
OLLAMA_TEST_MODEL: qwen3:0.6b
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-ollama-${{ hashFiles('**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Cache Ollama model blobs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
# No restore-keys on purpose: a partial hit from a different model would
|
||||
# silently test something other than OLLAMA_TEST_MODEL.
|
||||
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
|
||||
|
||||
- name: Install Ollama
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The official installer, but VERSION-PINNED via OLLAMA_VERSION (which it
|
||||
# honours) so this job cannot silently change what it tests. Fetching the
|
||||
# release asset directly is not a workable alternative: since v0.33 the
|
||||
# linux bundle ships as a 1.4 GB .tar.zst with GPU runtimes bundled in,
|
||||
# and the installer already handles the zst/tgz split across versions.
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
ollama --version
|
||||
|
||||
- name: Start Ollama and pull model
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ollama serve > /tmp/ollama-serve.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
|
||||
echo "Ollama is up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
|
||||
ollama pull "$OLLAMA_TEST_MODEL"
|
||||
ollama list
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
|
||||
- name: Run Ollama minimum contract (required)
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
# No "|| echo" here: no secret is involved, so a failure is a real
|
||||
# regression rather than a missing credential.
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short \
|
||||
--timeout=120
|
||||
|
||||
- name: Run remaining Ollama live tests (informational)
|
||||
if: always()
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
|
||||
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short \
|
||||
--timeout=180 \
|
||||
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
|
||||
|
||||
- name: Ollama server log
|
||||
if: failure()
|
||||
run: tail -100 /tmp/ollama-serve.log
|
||||
|
||||
# ============================================
|
||||
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
|
||||
# Nightly / extended tier only (~401 tests)
|
||||
@@ -956,7 +836,7 @@ jobs:
|
||||
# TEST SUMMARY
|
||||
# ============================================
|
||||
test-summary:
|
||||
needs: [smoke, main, extended-ollama]
|
||||
needs: [smoke, main]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ on:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- '.github/workflows/update-docs-parity.yml'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
name: Update Feature Parity Tracker
|
||||
|
||||
# Regenerates the exported-name parity trackers on push to main and commits them.
|
||||
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
# The generator's own code and the wrapper features it counts. Change
|
||||
# either and nothing regenerated until an unrelated push landed.
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- 'src/praisonai/praisonai/cli/features/**/*.py'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
- '.github/workflows/update-parity-tracker.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-parity-tracker:
|
||||
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
|
||||
# committing a regenerated tracker onto a contributor's branch is not this
|
||||
# workflow's business, and would fail on a fork besides.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -46,45 +32,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install the TypeScript compiler only
|
||||
# The signature extractor needs `typescript` and nothing else;
|
||||
# praisonai-ts's own dependency tree does not install cleanly.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/parity-ts"
|
||||
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
|
||||
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate parity trackers
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 src/praisonai/scripts/generate_parity_tracker.py
|
||||
|
||||
- name: Regenerate the signature report
|
||||
# Source locations in the report drift whenever a covered file is edited.
|
||||
# The pull-request gate ignores line-number-only drift so it never reddens
|
||||
# a build over it; refreshing here keeps the committed line numbers true.
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 -m praisonai._dev.parity.signatures --write
|
||||
|
||||
- name: Regenerate the behaviour report
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 -m praisonai._dev.parity.behaviour --write
|
||||
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json 2>/dev/null; then
|
||||
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md 2>/dev/null; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No changes to parity trackers"
|
||||
else
|
||||
@@ -98,7 +54,8 @@ jobs:
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "docs: auto-update feature parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json'
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "[email protected]"
|
||||
commit_author: "MervinPraison <[email protected]>"
|
||||
|
||||
+1
-4
@@ -115,7 +115,4 @@ Cargo.lock
|
||||
src/praisonai/build*.egg-info/
|
||||
src/praisonai/PraisonAI.egg-info/
|
||||
jobs/*
|
||||
*.egg-info
|
||||
# Local Android SDK/NDK/JDK for building praisonai-mobile on a laptop without
|
||||
# Android Studio (how: .github/workflows/mobile-platforms.yml). Several GB.
|
||||
.android-sdk/
|
||||
*.egg-info
|
||||
@@ -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.
|
||||
+12
-12
@@ -222,15 +222,15 @@ The Python SDK is organised as three publishable tiers (see [§2](#2-python-thre
|
||||
|
||||
Core execution modules live in `praisonaiagents`:
|
||||
|
||||
- `src/praisonai-agents/praisonaiagents/agent/` — Agent class, handoff, autonomy
|
||||
- `src/praisonai-agents/praisonaiagents/llm/` — Model runtime with provider routing, rate limiting, failover
|
||||
- `src/praisonai-agents/praisonaiagents/tools/` — Tool runtime with sandbox, approval, retry
|
||||
- `src/praisonai-agents/praisonaiagents/memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
|
||||
- `src/praisonai-agents/praisonaiagents/knowledge/` — Knowledge management (indexing, retrieval, chunking)
|
||||
- `src/praisonai-agents/praisonaiagents/workflows/` — Workflow engine (YAML/SDK-based orchestration)
|
||||
- `src/praisonai-agents/praisonaiagents/hooks/`, `src/praisonai-agents/praisonaiagents/bus/` — Hook system and event bus
|
||||
- `src/praisonai-agents/praisonaiagents/mcp/` — MCP protocol support
|
||||
- `src/praisonai-agents/praisonaiagents/ui/a2a/`, `src/praisonai-agents/praisonaiagents/ui/a2ui/` — Agent-to-agent and agent-to-UI protocols
|
||||
- `agent/` — Agent class, handoff, autonomy
|
||||
- `llm/` — Model runtime with provider routing, rate limiting, failover
|
||||
- `tools/` — Tool runtime with sandbox, approval, retry
|
||||
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
|
||||
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
|
||||
- `workflows/` — Workflow engine (YAML/SDK-based orchestration)
|
||||
- `hooks/`, `bus/` — Hook system and event bus
|
||||
- `mcp/` — MCP protocol support
|
||||
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols (wrapper/SDK)
|
||||
|
||||
Wrapper-only surfaces remain in `src/praisonai/`:
|
||||
|
||||
@@ -243,9 +243,9 @@ Wrapper-only surfaces remain in `src/praisonai/`:
|
||||
- **Python Core SDK** — `src/praisonai-agents/` (`praisonaiagents`)
|
||||
- **Python Terminal CLI** — `src/praisonai-code/` (`praisonai-code`)
|
||||
- **Python Wrapper** — `src/praisonai/` (`praisonai`)
|
||||
- **TypeScript SDK** — JS/TS runtime (`src/praisonai-ts/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`src/praisonai-rust/`)
|
||||
- **UI** — Web UI applications (`src/praisonai/praisonai/ui_chat/`, `src/praisonai/praisonai/ui_agents/`, `src/praisonai/praisonai/ui_bot/`, `src/praisonai/praisonai/ui_realtime/`); shared support code lives in `src/praisonai/praisonai/ui/`.
|
||||
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
|
||||
- **UI** — Web UI (`ui/`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -35,19 +35,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
@@ -1112,39 +1099,13 @@ For other providers, see [Models docs](https://docs.praison.ai/docs/models).
|
||||
<summary><strong>How do I use a local model (Ollama)?</strong></summary>
|
||||
|
||||
```bash
|
||||
# Start Ollama and pull a model
|
||||
# Start Ollama server first
|
||||
ollama serve
|
||||
ollama pull llama3.2
|
||||
|
||||
# Set environment variable
|
||||
export OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="You are a helpful assistant", llm="ollama/llama3.2")
|
||||
agent.start("Why is the sky blue?")
|
||||
```
|
||||
|
||||
The `ollama/` prefix is what selects Ollama's handling — tool-call repair,
|
||||
tool-result formatting and the streaming rules small local models need. Or set
|
||||
it by environment instead:
|
||||
|
||||
```bash
|
||||
export OPENAI_MODEL_NAME=ollama/llama3.2
|
||||
```
|
||||
|
||||
Setting only `OPENAI_BASE_URL` is not enough: with no model named, the OpenAI
|
||||
default (`gpt-4o-mini`) is sent to Ollama, which answers
|
||||
`404 model 'gpt-4o-mini' not found`. Always name the model.
|
||||
|
||||
Point at a non-default host with `base_url=` or `OLLAMA_HOST`:
|
||||
|
||||
```python
|
||||
agent = Agent(instructions="...", llm="ollama/llama3.2", base_url="http://192.168.1.10:11434")
|
||||
```
|
||||
|
||||
The same shape works for other local runtimes — `lm_studio/`, `vllm/` and
|
||||
`hosted_vllm/` with their server's `base_url`.
|
||||
|
||||
See [Models docs](https://docs.praison.ai/docs/models) for more details.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -68,8 +68,6 @@ 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>
|
||||
@@ -309,7 +307,7 @@ Methods:
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">normalize_content</a>(content)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">reset</a>()</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">search</a>(query, user_id = None, agent_id = None, run_id = None, rerank = None, **kwargs)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None, is_content = False)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">update</a>(memory_id, data)</code>
|
||||
* <code title="class KnowledgeConfig">KnowledgeConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -387,7 +385,6 @@ Methods:
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_input</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_output</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_call</a>(tool_name: str, arguments: Dict[str, Any], **kwargs) -> Tuple[bool, Dict[str, Any]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_result</a>(tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -782,7 +779,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -907,7 +904,6 @@ Methods:
|
||||
* <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>
|
||||
@@ -926,23 +922,17 @@ Methods:
|
||||
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createVideoAgent, createVisionAgent } from "./agent";
|
||||
export type { AgentChatCallOptions, AgentChatOptions, AgentEvent, AgentExecuteTask, AgentGuardrailEntry, AgentGuardrailFunction, AgentGuardrailInput, AgentHooksInput, AgentMemoryStore, AgentMessage, AgentRetryConfig, AgentStreamOptions, AgentTaskLike, AgentTeamConfig, AgentTeamProcess, AgentTeamStartDictOptions, AgentTeamStartOptions, AgentTeamStartOptionsInput, AgentWebConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason, TaskCallback, TaskGuardrail, TaskOnError } from "./agent";
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
export type { AudioAgentConfig, AudioConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent, create_context_agent } from "./agent/context";
|
||||
export { ContextPolicy, DEFAULT_HANDOFF_TOOL_POLICY, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, HandoffToolPolicyMode, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, handoffToolName, handoff_filters, listAgentTools, parallelHandoffs, parallel_handoffs, promptWithHandoffInstructions, prompt_with_handoff_instructions, resolveHandoffToolPolicy } from "./agent/handoff";
|
||||
export { Heartbeat, HeartbeatConfig, parseHeartbeatSchedule } from "./agent/heartbeat";
|
||||
export type { HeartbeatAgent, HeartbeatOnError, HeartbeatOptions } from "./agent/heartbeat";
|
||||
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent } from "./agent/context";
|
||||
export { ContextPolicy, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, promptWithHandoffInstructions } from "./agent/handoff";
|
||||
export { ImageAgent, createImageAgent } from "./agent/image";
|
||||
export { PromptExpanderAgent, createPromptExpanderAgent } from "./agent/prompt-expander";
|
||||
export { QueryRewriterAgent, createQueryRewriterAgent } from "./agent/query-rewriter";
|
||||
export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/research";
|
||||
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
|
||||
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
|
||||
export { DeepResearchAgent, createDeepResearchAgent } from "./agent/research";
|
||||
export { RouterAgent, createRouter, routeConditions } from "./agent/router";
|
||||
export { AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
|
||||
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, TerminalReason } from "./agent/run-outcome";
|
||||
export { // Agent loop
|
||||
createAgentLoop, // DevTools
|
||||
enableDevTools, // MCP
|
||||
@@ -959,7 +949,6 @@ export { // Agent loop
|
||||
defineTool, // UI Message (AI SDK v6 parity)
|
||||
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MCPClientType, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
|
||||
export { AutoAgents, AutoTaskConfig, createAutoAgents } from "./auto";
|
||||
export { BotRunStatus } from "./bots/protocols";
|
||||
export { BaseCache, FileCache, MemoryCache, createFileCache, createMemoryCache } from "./cache";
|
||||
export { CLI_SPEC_VERSION, executeCommand, parseArgs } from "./cli";
|
||||
export { // Autonomy Mode
|
||||
@@ -981,27 +970,23 @@ export { // Autonomy Mode
|
||||
export { // Classes
|
||||
DictCondition, // Functions
|
||||
evaluateCondition, // Types
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, evaluate_condition, notCondition, orConditions } from "./conditions";
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, notCondition, orConditions } from "./conditions";
|
||||
export { // Enums
|
||||
MemoryBackend, // Errors
|
||||
ConfigValidationError, // Parse utilities
|
||||
detect_url_scheme, // Presets
|
||||
MEMORY_PRESETS, // Resolver functions
|
||||
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, LearnBackend, LearnMode, LearnScope, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, PreCompactionMemoryFlushConfig, REFLECTION_PRESETS, RulesConfig, ToolSearchConfig, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
|
||||
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, REFLECTION_PRESETS, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
|
||||
export { createDbAdapter, db, getDefaultDbAdapter, setDefaultDbAdapter } from "./db";
|
||||
export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
|
||||
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
|
||||
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
|
||||
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
|
||||
export { // Functions
|
||||
// NB: the unaliased registerDisplayCallback name belongs to the per-type
|
||||
// callback registry (callbacks module, // Types
|
||||
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, async_display_callbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, errorLogs, error_logs, logError, registerDisplay, register_display_callback, syncDisplayCallbacks, sync_display_callbacks } from "./display";
|
||||
export { // Types
|
||||
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, errorLogs, logError, registerDisplay, syncDisplayCallbacks } from "./display";
|
||||
export { // Functions
|
||||
embed, // Types
|
||||
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, get_dimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
|
||||
export { AGENT_ERROR_KINDS, FailoverDecision, IdleTimeoutBreaker, LEGACY_ERROR_CATEGORY_MAP, LLMError, NetworkError, PraisonAIConfigError, PraisonAIError, ToolExecutionError, ValidationError, isAgentErrorKind, isErrorContext, resolveErrorCategory } from "./errors";
|
||||
export type { AgentErrorKind, ErrorContextProtocol, FailoverAction, FailoverDecisionOptions, LLMErrorOptions, LegacyErrorCategory, NetworkErrorOptions, PraisonAIConfigErrorOptions, PraisonAIErrorOptions, ToolExecutionErrorOptions, ValidationErrorOptions } from "./errors";
|
||||
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
|
||||
export { // LLM-as-Judge
|
||||
Judge, AccuracyJudge, CriteriaJudge, EvalResults, EvalSuite, Evaluator, RecipeJudge, accuracyEval, addJudge, addOptimizationRule, containsKeywordsCriterion, createDefaultEvaluator, createEvalResults, createEvaluator, getJudge, getOptimizationRule, lengthCriterion, listJudges, listOptimizationRules, noHarmfulContentCriterion, parseJudgeResponse, performanceEval, relevanceCriterion, reliabilityEval, removeJudge, removeOptimizationRule } from "./eval";
|
||||
export { AgentEventBus, AgentEvents, EventEmitterPubSub, PubSub, createEventBus, createPubSub } from "./events";
|
||||
@@ -1010,7 +995,7 @@ export { // Bot types
|
||||
FailoverManager, // Enums
|
||||
SandboxStatus, // Gateway types
|
||||
type GatewayConfig, // Other types
|
||||
type ProviderStatus, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
|
||||
type ProviderStatus, AutonomyLevel, RagRetrievalPolicy } from "./gateway";
|
||||
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
|
||||
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
|
||||
export { // Computer Use
|
||||
@@ -1022,11 +1007,7 @@ export { // Slack
|
||||
createSlackBot, SlackBot, parseSlackMessage, verifySlackSignature } from "./integrations/slack";
|
||||
export { BaseVectorStore, ChromaVectorStore, MemoryVectorStore, PineconeVectorStore, QdrantVectorStore, VectorQueryResult, WeaviateVectorStore, createChromaStore, createMemoryVectorStore, createPineconeStore, createQdrantStore, createWeaviateStore } from "./integrations/vector";
|
||||
export { BaseVoiceProvider, ElevenLabsVoiceProvider, OpenAIVoiceProvider, createElevenLabsVoice, createOpenAIVoice } from "./integrations/voice";
|
||||
export type { KnowledgeRecord } from "./knowledge";
|
||||
export { GraphRAG, GraphStore, createGraphRAG } from "./knowledge/graph-rag";
|
||||
export { Knowledge } from "./knowledge/knowledge";
|
||||
export type { KnowledgeIndexOptions, KnowledgeSearchCallOptions, KnowledgeStoreConfig, KnowledgeStoreOptions } from "./knowledge/knowledge";
|
||||
export { KnowledgeBase, KnowledgeDocument, KnowledgeSearchResult, createKnowledgeBase } from "./knowledge/rag";
|
||||
export { BaseReranker, CohereReranker, CrossEncoderReranker, LLMReranker, createCohereReranker, createCrossEncoderReranker, createLLMReranker } from "./knowledge/reranker";
|
||||
export { // Provider classes
|
||||
OpenAIProvider, // Provider factory and utilities
|
||||
@@ -1034,22 +1015,14 @@ export { // Provider classes
|
||||
ProviderRegistry, // Types
|
||||
type LLMProvider, AnthropicProvider, BaseProvider, GoogleProvider, ProviderMessage, ProviderToolDefinition, createProviderRegistry, getAvailableProviders, getDefaultProvider, getDefaultRegistry, hasProvider, isProviderAvailable, listProviders, parseModelString, registerBuiltinProviders, registerProvider, unregisterProvider } from "./llm/providers";
|
||||
export { ADAPTERS, AISDK_PROVIDERS, COMMUNITY_PROVIDERS, PROVIDER_ALIASES } from "./llm/providers/ai-sdk/types";
|
||||
export { AgentMessageEvent, CustomToolUseEvent, ManagedEvent, SessionErrorEvent, SessionIdleEvent, SessionRunningEvent, ToolConfirmationEvent, ToolUseEvent, UsageEvent, isManagedBackend } from "./managed";
|
||||
export type { AgentMessageEventInit, CustomToolUseEventInit, ManagedBackendKwargs, ManagedBackendProtocol, ManagedContentBlock, ManagedEventInit, ManagedEventType, ManagedStopReason, SessionErrorEventInit, SessionIdleEventInit, ToolConfirmationEventInit, ToolUseEventInit, UsageEventInit } from "./managed";
|
||||
export { MCPClient, MCPSecurity, MCPServer, MCPSessionManager, createApiKeyPolicy, createMCPClient, createMCPSecurity, createMCPServer, createMCPSession, createRateLimitPolicy, getMCPTools } from "./mcp";
|
||||
export { ChromaMemory, InMemoryAdapter, MEMORY_PROVIDER_ALIASES, MemoryAdapterRegistry, addMemoryAdapter, addMemoryFactory, add_memory_adapter, add_memory_factory, createChromaMemoryAdapter, createDakeraMemoryAdapter, createMem0MemoryAdapter, createMongodbMemoryAdapter, createSqliteMemoryAdapter, getDefaultMemoryRegistry, getFirstAvailableMemoryAdapter, getMemoryAdapter, get_memory_adapter, hasMemoryAdapter, has_memory_adapter, listMemoryAdapters, list_memory_adapters, registerMemoryAdapter, registerMemoryFactory, register_memory_adapter, register_memory_factory, resetDefaultMemoryRegistry, resolveMemoryAdapterName, sanitizeChromaMetadata } from "./memory/adapters";
|
||||
export type { ChromaMemoryConfig, MaybePromise, MemoryEmbedder, MemoryProtocol, MemoryRecord } from "./memory/adapters";
|
||||
export { AutoMemory, AutoMemoryKnowledgeBase, AutoMemoryVectorStore, DEFAULT_POLICIES, createAutoMemory, createLLMSummarizer } from "./memory/auto-memory";
|
||||
export { DocsManager, createDocsManager } from "./memory/docs-manager";
|
||||
export { FileMemory, createFileMemory } from "./memory/file-memory";
|
||||
export { MemoryHooks, createEncryptionHooks, createLoggingHooks, createMemoryHooks, createValidationHooks } from "./memory/hooks";
|
||||
export { BaseStore, DecisionStore, FeedbackStore, ImprovementStore, InsightStore, LearnBackendNotAvailableError, LearnEntry, LearnError, LearnManager, LearnManagerMode, MongoDBLearnBackend, PatternStore, PersonaStore, RedisLearnBackend, SQLiteLearnBackend, ThreadStore, getDataDir, getLearnDir, resolveLearnConfig, toLearnEntryDict } from "./memory/learn";
|
||||
export type { AsyncLearnProtocol, BaseStoreOptions, LearnEntryConvertible, LearnEntryData, LearnEntryInit, LearnEntryLike, LearnExtractor, LearnManagerConfig, LearnManagerProtocol, LearnMessage, LearnProtocol, LearnStorageBackend, LearnStore, ProcessConversationResult, ResolvedLearnConfig } from "./memory/learn";
|
||||
export { Memory, createMemory } from "./memory/memory";
|
||||
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
|
||||
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
|
||||
export { DEFAULT_PROFILE, HarnessProfile, listHarnessProfiles, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
|
||||
export type { HarnessProfileConfig, HarnessRegistryEntry, HarnessResolverProtocol } from "./model-harness";
|
||||
export { // Adapters
|
||||
NoopObservabilityAdapter, // Constants
|
||||
OBSERVABILITY_TOOLS, // Global adapter management
|
||||
@@ -1057,6 +1030,15 @@ export { // Adapters
|
||||
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
|
||||
export { AgentApp, AgentAppConfig, AgentAppProtocol, AgentOS, AgentOSConfig, AgentOSProtocol, DEFAULT_AGENTOS_CONFIG, mergeConfig } from "./os";
|
||||
export type { AgentAppOptions, AgentOSOptions } from "./os";
|
||||
export { // P0: Call Types (new)
|
||||
type MCPCall, // P0: Specialized Agent Configs (new)
|
||||
type AudioConfig, // P0: Specialized Agents (new)
|
||||
CodeAgent, // P1: Workflow Patterns (new)
|
||||
Chunking, // P2: Context Types (new - only items not already exported)
|
||||
ContextManager, // P3: Display callbacks (snake_case for Python parity)
|
||||
register_display_callback, // P3: Plugin functions
|
||||
get_plugin_manager, // P3: Trace & condition functions
|
||||
evaluate_condition, EmbeddingAgent, If, Knowledge, MCP, OCRAgent, Parallel, RealtimeAgent, Route, Session, VideoAgent, VisionAgent, async_display_callbacks, cleanup_telemetry_resources, create_context_agent, disable_performance_mode, disable_telemetry, discover_and_load_plugins, discover_plugins, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, enable_performance_mode, enable_telemetry, ensure_plugin_dir, error_logs, get_default_plugin_dirs, get_dimensions, get_plugin_template, get_telemetry, handoff_filters, load_plugin, parse_plugin_header, parse_plugin_header_from_file, prompt_with_handoff_instructions, resolve_guardrail_policies, sync_display_callbacks, trace_context, track_workflow, when } from "./parity";
|
||||
export { // Core classes
|
||||
Plan, // Python parity additions
|
||||
ApprovalCallback, PlanStep, PlanStorage, PlanningAgent, READ_ONLY_TOOLS, RESEARCH_TOOLS, RESTRICTED_TOOLS, TaskAgent, TodoItem, TodoList, createApprovalCallback, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
|
||||
@@ -1064,51 +1046,36 @@ export { // Classes
|
||||
Plugin, // Enums
|
||||
PluginHook, // Functions
|
||||
getPluginManager, // Interfaces
|
||||
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, discover_and_load_plugins, discover_plugins, enablePlugins, ensurePluginDir, ensure_plugin_dir, getDefaultPluginDirs, getPluginTemplate, get_default_plugin_dirs, get_plugin_manager, get_plugin_template, isPluginEnabled, listPlugins, loadPlugin, load_plugin, parsePluginHeader, parsePluginHeaderFromFile, parse_plugin_header, parse_plugin_header_from_file } from "./plugins";
|
||||
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, enablePlugins, ensurePluginDir, getDefaultPluginDirs, getPluginTemplate, isPluginEnabled, listPlugins, loadPlugin, parsePluginHeader, parsePluginHeaderFromFile } from "./plugins";
|
||||
export { // A2A Protocol
|
||||
A2ATaskState, // AGUI Protocol
|
||||
AGUI, // AgentManager alias type
|
||||
type AgentManager, // Global singletons
|
||||
config, // Guardrail policies
|
||||
type GuardrailPolicy, // Tools class
|
||||
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, resolve_guardrail_policies, workflows } from "./protocols";
|
||||
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, workflows } from "./protocols";
|
||||
export { CitationsMode, DEFAULT_RAG_TEMPLATE, RAG, RAGCitation, RAGContextPack, RetrievalPolicy, RetrievalStrategy, createCitation, createContextPack, createRAG, createRAGConfig, createRAGResult, createRetrievalConfig, createSimpleRetrievalConfig, createSmartRetrievalConfig, formatAnswerWithCitations, formatCitation, formatContextPackForPrompt, hasCitations } from "./rag";
|
||||
export { PraisonAIRuntime, RuntimeRegistry, RuntimeRegistryEntry, RuntimeRegistryError, addRuntimeAlias, getRuntimeRegistry, isAgentRuntime, isRuntimeAvailable, listRuntimes, list_runtimes, registerRuntime, register_runtime, resolveRuntime, resolve_runtime, unregisterRuntime } from "./runtime";
|
||||
export type { AgentRuntimeProtocol, RunTurnOptions, RuntimeCapabilityMatrix, RuntimeConfig, RuntimeDelta, RuntimeDeltaType, RuntimeFactory, RuntimeMode, RuntimeRegistryEntryInit, RuntimeRegistryProtocol, RuntimeResult, TurnContextBuilderProtocol, TurnRuntimeProtocol } from "./runtime";
|
||||
export type { RunStatus } from "./session";
|
||||
export { Session } from "./session/session";
|
||||
export { // Python parity additions
|
||||
SkillLoader, EnforcementLevel, SkillManager, SkillParseError, SkillState, createSkillLoader, createSkillManager, createSkillProperties, discoverSkill, discoverSkills, discover_skills, findSkillMd, getDefaultSkillDirs, loadSkill, load_skill, parseFrontmatter, parseSkillFile, validate, validateMetadata, validateSkill, validate_metadata } from "./skills";
|
||||
export type { RemoteSkillSource } from "./skills";
|
||||
SkillLoader, SkillManager, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from "./skills";
|
||||
export { AgentTask, AgentTaskConfig, BaseTask, createTaskOutput } from "./task";
|
||||
export { // Python parity additions
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, cleanup_telemetry_resources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, disable_performance_mode, disable_telemetry, enablePerformanceMode, enableTelemetry, enable_performance_mode, enable_telemetry, getMinimalTelemetry, getTelemetry, get_telemetry } from "./telemetry";
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, TOOL_TRUST_LEVELS, ToolRegistry, ToolResult, ToolValidationError, add_tool, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, getToolDefinitions, get_registry, get_tool, get_tool_definitions, hasTool, has_tool, listTools, list_tools, registerTool, register_tool, removeTool, remove_tool, tool, validateTool, validate_tool } from "./tools";
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, crwCrawl, crwScrape, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { MCP, MCPToolTransportType } from "./tools/mcp";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolConstructionError, ToolNotRegisteredError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolInstance, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, registerToolFactory, resetToolsRegistry, tryCreateToolInstance, validateToolInstall } from "./tools/registry";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { TOOLSET_TOOL_ID_MAP, ToolsetRegistry, ToolsetSpec, getToolset, getToolsetRegistry, get_toolset, get_toolset_registry, hasToolset, has_toolset, listToolsets, list_toolsets, registerToolset, register_toolset, resolveToolset, resolveToolsetBuiltinIds, resolveToolsets, resolveToolsetsForModel, resolve_toolset, resolve_toolsets, resolve_toolsets_for_model, toolsetToolId, unregisterToolset, unregister_toolset } from "./toolsets";
|
||||
export type { ToolsetSpecConfig } from "./toolsets";
|
||||
export { // Classes
|
||||
TraceSink, // Enums
|
||||
ContextEventType, // Functions
|
||||
createContextEvent, // Types
|
||||
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trace_context, trackWorkflow, track_workflow } from "./trace";
|
||||
export { A2UI, A2UINotInstalledError, A2UI_MIME_TYPE, createA2uiPart, getSchemaManager, isA2uiPart, parseA2uiResponse } from "./ui/a2ui";
|
||||
export type { A2UIAdapter, A2UISystemPromptOptions, A2UIToolResultProtocol } from "./ui/a2ui";
|
||||
export { AdapterBackendNotAvailableError, AdapterCreationError, AdapterRegistry, isBackendUnavailableError } from "./utils/adapter-registry";
|
||||
export type { AdapterClass, AdapterFactory, AdapterKwargs } from "./utils/adapter-registry";
|
||||
export { Logger, PraisonLogger, ROOT_LOGGER_NAME, StructuredFormatter, configureStructuredLogging, configure_structured_logging, getLogger, get_logger, normalizeLoggerName } from "./utils/logger";
|
||||
export type { GetLoggerOptions, LogFormatter, LogLevelName, LogRecord, StructuredFormatterOptions } from "./utils/logger";
|
||||
export { VERSION, __version__, getVersion } from "./version";
|
||||
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trackWorkflow } from "./trace";
|
||||
export { // New: Python-parity Loop and Repeat classes
|
||||
Loop, // Task class
|
||||
Task, AgentFlow, DEFAULT_MAX_PARALLEL_WORKERS, If, Include, MAX_NESTING_DEPTH, NestingDepthError, PARALLEL_ON_FAILURE_MODES, Parallel, Pipeline, Repeat, Route, Workflow, WorkflowStepError, evaluateWorkflowCondition, getRecipeResolver, ifStep, if_, include, isControlFlowPattern, loop, loopPattern, parallel, parallelPattern, repeat, repeatPattern, route, routePattern, setRecipeResolver, substituteWorkflowVariables, when } from "./workflows";
|
||||
export type { AgentLikeStep, FlowStep, IncludableWorkflow, LoopConfig, LoopResult, ParallelBranchError, ParallelOnFailure, RecipeResolver, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
|
||||
export { YAMLWorkflowParser, createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
|
||||
Task, AgentFlow, Pipeline, Repeat, Workflow, loop, loopPattern, parallel, repeat, repeatPattern, route } from "./workflows";
|
||||
export type { LoopConfig, LoopResult, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
|
||||
export { createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
|
||||
```
|
||||
|
||||
# Optional Plugins
|
||||
|
||||
@@ -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.6" \
|
||||
"praisonai>=4.7.0" \
|
||||
"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.6" \
|
||||
"praisonai>=4.7.0" \
|
||||
"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.6" \
|
||||
"praisonai>=4.7.0" \
|
||||
"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__":
|
||||
|
||||
+3
-34
@@ -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
|
||||
@@ -163,13 +136,9 @@ These examples comply with MCP Protocol Version 2025-11-25:
|
||||
|
||||
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.
|
||||
|
||||
Clone [`GrokBuildRemote-Agents`](https://github.com/LinespottingOrg/GrokBuildRemote-Agents), run `npm install` in `mcp/gbr-mcp`, then point `GBR_MCP_JS` at the entrypoint (defaults to `GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js` relative to the clone; resolved to an absolute path so the example runs from any directory):
|
||||
|
||||
```bash
|
||||
export GBR_MCP_JS=/abs/path/to/GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js
|
||||
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
|
||||
+13
-39
@@ -19,37 +19,21 @@ Independent product by Linespotting AB. Not affiliated with xAI or SpaceX.
|
||||
https://grokbuildremote.com/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git
|
||||
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git then point
|
||||
# GBR_MCP_JS at mcp/gbr-mcp/bin/gbr-mcp.js (loopback stdio). Resolved to an
|
||||
# absolute path so the example runs from any working directory. Override with:
|
||||
# export GBR_MCP_JS=/abs/path/to/GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js
|
||||
GBR_MCP_JS = os.path.abspath(
|
||||
os.environ.get(
|
||||
"GBR_MCP_JS",
|
||||
"GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js",
|
||||
)
|
||||
)
|
||||
|
||||
## 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. "
|
||||
@@ -57,20 +41,10 @@ gbr = Agent(
|
||||
"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"],
|
||||
),
|
||||
tools=MCP("node", args=[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")
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Meeting Notes Agent
|
||||
|
||||
An AI meeting-notes pipeline composed entirely from existing PraisonAI
|
||||
primitives — no new SDK surface required:
|
||||
|
||||
1. AudioAgent.transcribe() -> Whisper speech-to-text
|
||||
2. Agent (summary) -> structured summary + action items
|
||||
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
|
||||
|
||||
Usage:
|
||||
python meeting-notes-agent.py path/to/recording.mp3
|
||||
|
||||
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from praisonaiagents import Agent, AudioAgent
|
||||
|
||||
|
||||
def transcribe_meeting(audio_path: str) -> str:
|
||||
"""Speech-to-text using the built-in Whisper AudioAgent."""
|
||||
stt = AudioAgent(llm="openai/whisper-1")
|
||||
return stt.transcribe(audio_path)
|
||||
|
||||
|
||||
def summarize_meeting(transcript: str) -> str:
|
||||
"""Summarize decisions, topics, and action items."""
|
||||
notetaker = Agent(
|
||||
name="Meeting Notetaker",
|
||||
instructions=(
|
||||
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
|
||||
"1. A concise summary (3-5 sentences).\n"
|
||||
"2. Key decisions made.\n"
|
||||
"3. Action items as a checklist with owners when mentioned.\n"
|
||||
"Only include action items that were actually spoken."
|
||||
),
|
||||
)
|
||||
return notetaker.start(f"Transcript:\n\n{transcript}")
|
||||
|
||||
|
||||
def build_qa_agent(transcript_path: str) -> Agent:
|
||||
"""Index the transcript for semantic search and cross-meeting Q&A."""
|
||||
return Agent(
|
||||
name="Meeting Q&A",
|
||||
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
|
||||
knowledge=[transcript_path],
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python meeting-notes-agent.py <audio_file>")
|
||||
sys.exit(1)
|
||||
|
||||
audio_path = sys.argv[1]
|
||||
|
||||
transcript = transcribe_meeting(audio_path)
|
||||
transcript_path = "meeting_transcript.txt"
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
f.write(transcript)
|
||||
|
||||
notes = summarize_meeting(transcript)
|
||||
print("\n=== Meeting Notes ===\n")
|
||||
print(notes)
|
||||
|
||||
qa = build_qa_agent(transcript_path)
|
||||
print("\n=== Q&A ===\n")
|
||||
print(qa.start("What were the main action items and who owns them?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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,13 +2,6 @@
|
||||
|
||||
> **For AI Agents and Developers**: This document provides the complete context needed to work with the PraisonAI ecosystem, including design principles, architecture, repository structure, and implementation guidelines.
|
||||
|
||||
> **Active workstream — local model runtimes.** If your task touches local inference
|
||||
> (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`), provider detection,
|
||||
> `llm/adapters/`, or the `praisonaiagents/local/` package, read
|
||||
> [`docs/local-model-layer/README.md`](docs/local-model-layer/README.md) **before editing**.
|
||||
> It carries the measured ground truth, the invariants, and a file-ownership table that
|
||||
> prevents concurrent work orders from corrupting each other.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is PraisonAI?
|
||||
@@ -214,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.
|
||||
@@ -230,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)
|
||||
@@ -895,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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,78 +17,78 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 57 | 18177 |
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 53 | 17053 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
|
||||
| ✅ Approval | 1 | 7 | 3616 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2658 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3879 |
|
||||
| ✅ Autonomy | 3 | 7 | 2794 |
|
||||
| ✅ Bots | 7 | 33 | 13928 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3542 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 31 | 13411 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 123 | 38201 |
|
||||
| ✅ Chunking | 2 | 2 | 424 |
|
||||
| ✅ CLI | 5 | 122 | 36734 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1386 |
|
||||
| ✅ Configuration | 3 | 8 | 3323 |
|
||||
| ✅ Context Management | 16 | 36 | 14104 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3139 |
|
||||
| ✅ Context Management | 16 | 36 | 13988 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 844 |
|
||||
| ✅ Embeddings | 6 | 23 | 2051 |
|
||||
| ✅ Evaluation | 1 | 8 | 3220 |
|
||||
| ✅ Events | 1 | 2 | 767 |
|
||||
| ✅ Execution | 3 | 4 | 1631 |
|
||||
| ✅ Failover | 2 | 1 | 480 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 86 | 32228 |
|
||||
| ✅ Guardrails | 4 | 4 | 2388 |
|
||||
| ✅ Handoffs | 11 | 6 | 2718 |
|
||||
| ✅ Hooks | 2 | 9 | 4690 |
|
||||
| ✅ Gateway | 7 | 80 | 29490 |
|
||||
| ✅ Guardrails | 4 | 4 | 2205 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4488 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 18 | 5562 |
|
||||
| ✅ LLM | 3 | 15 | 5917 |
|
||||
| ✅ Knowledge | 4 | 16 | 4942 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 62 | 14489 |
|
||||
| ✅ Memory | 6 | 19 | 7588 |
|
||||
| ✅ MCP | 1 | 61 | 14135 |
|
||||
| ✅ Memory | 6 | 18 | 7187 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2815 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1273 |
|
||||
| ✅ Output | 3 | 5 | 1265 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3124 |
|
||||
| ✅ Prompts | 2 | 9 | 2018 |
|
||||
| ✅ Providers | 1 | 57 | 8636 |
|
||||
| ✅ Planning | 6 | 6 | 1605 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7781 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3122 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 732 |
|
||||
| ✅ Retrieval | 2 | 5 | 1042 |
|
||||
| ✅ Reflection | 3 | 3 | 730 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3670 |
|
||||
| ✅ Security | 1 | 3 | 3558 |
|
||||
| ✅ Sessions | 4 | 20 | 7898 |
|
||||
| ✅ Skills | 6 | 16 | 5984 |
|
||||
| ✅ Tasks | 2 | 8 | 3231 |
|
||||
| ✅ Telemetry | 1 | 2 | 660 |
|
||||
| ✅ Templates | 1 | 8 | 1667 |
|
||||
| ✅ Tools | 12 | 145 | 44258 |
|
||||
| ✅ Sandbox | 5 | 7 | 3483 |
|
||||
| ✅ Security | 1 | 3 | 3482 |
|
||||
| ✅ Sessions | 4 | 19 | 7576 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 144 | 43102 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Video | 2 | 6 | 728 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 10 | 2791 |
|
||||
| ✅ Workflows | 5 | 18 | 7446 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (43 docs, 7573 lines)
|
||||
- ℹ️ Documents (1 docs, 810 lines)
|
||||
- ℹ️ Database (42 docs, 6959 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
# Ground truth
|
||||
|
||||
Every claim here was measured, not estimated. Each carries **how** it was obtained so it can
|
||||
be re-checked cheaply. Measured 2026-09-03 against `origin/main` at `2591aa405`, with a live
|
||||
Ollama 0.33.2 (`b79067b0`) on `127.0.0.1:11434`.
|
||||
|
||||
If a work order contradicts something here, re-measure before acting. Numbers drift.
|
||||
|
||||
> **Capabilities are per-model, not per-server.** An early draft of this file attributed the
|
||||
> 9B model's capability list to `qwen3:0.6b`. Re-verified 2026-09-03: `qwen3:0.6b` is
|
||||
> `["completion","tools","thinking"]` — **no vision**. Always name the model when quoting a
|
||||
> capability set.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runtime facts, verified live
|
||||
|
||||
| Fact | Value | How to re-verify |
|
||||
|---|---|---|
|
||||
| Ollama liveness probe | `GET /` returns `200` with body `Ollama is running` | `curl -sI 127.0.0.1:11434/` |
|
||||
| Ollama version probe | `{"version":"0.33.2"}` | `curl -s 127.0.0.1:11434/api/version` |
|
||||
| Capability probe works | `qwen3:0.6b` → `["completion","tools","thinking"]` (the 9B tamil model, separately, → `[...,"vision"]`) | `curl -s :11434/api/show -d '{"model":"qwen3:0.6b"}' \| jq .capabilities` |
|
||||
| Unknown `options` keys are silently dropped | `{"totally_bogus_key":123}` → HTTP 200, no warning | send it to `/api/chat`; response is a normal completion |
|
||||
| Tool-param schema keywords are stripped | `minLength`, `format`, `default`, `additionalProperties` all absent from the rendered prompt | `/api/chat` with `_debug_render_only: true` and those keys set |
|
||||
| `parameters.type` must be a bare string | `"type": ["object","null"]` → **HTTP 400** `cannot unmarshal array into Go struct field ToolFunctionParameters...type of type string` | send that tool definition to `/api/chat` |
|
||||
| **`format` + `tools` are mutually destructive** | Tool call silently suppressed; model fabricates. Same request without `format` calls the tool correctly. | see §2 below |
|
||||
|
||||
### Installed on this machine
|
||||
|
||||
`ollama` (0.33.2), `llama-server` (b7620, Homebrew), `mlx_lm.server`, `hf` CLI.
|
||||
**Not** installed: `lms` (LM Studio), `vllm`.
|
||||
Ports open at time of measurement: `11434` only.
|
||||
|
||||
---
|
||||
|
||||
## 2. The reproduction that matters most
|
||||
|
||||
`format` (JSON schema) plus `tools`, sent to Ollama, produces a **confident fabrication** with
|
||||
an HTTP 200 and no warning. The JSON grammar makes the tool-call tag unemittable, so the
|
||||
model cannot call the tool and answers from nothing instead.
|
||||
|
||||
```
|
||||
POST /api/chat model=qwen3:0.6b think=false
|
||||
prompt: "What is the weather in Paris? Use the tool."
|
||||
tools: [get_weather(city: string)]
|
||||
|
||||
WITH format + tools -> HTTP 200
|
||||
message.tool_calls : null
|
||||
message.content : {"answer": "The weather in Paris is sunny with a temperature of 15C."}
|
||||
|
||||
TOOLS ONLY (format removed) -> HTTP 200
|
||||
message.tool_calls : [{"function": {"name": "get_weather",
|
||||
"arguments": {"city": "Paris"}}}]
|
||||
message.content : ""
|
||||
```
|
||||
|
||||
Consequence for design: this combination must **raise**, not be silently repaired. A
|
||||
fabricated answer that looks correct is worse than an error.
|
||||
|
||||
---
|
||||
|
||||
## 3. Codebase measurements
|
||||
|
||||
All obtained by script over `praisonaiagents/` at `2591aa405`.
|
||||
|
||||
### Scale
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Python files / lines | 595 / 238,936 |
|
||||
| Files over 2,000 lines | 13 |
|
||||
| `agent/agent.py` | 7,701 |
|
||||
| `llm/llm.py` | 7,160 |
|
||||
| `agent/chat_mixin.py` | 6,151 |
|
||||
| `llm/openai_client.py` | 2,691 |
|
||||
| `llm/adapters/__init__.py` | 340 |
|
||||
|
||||
### `LLM` class cohesion
|
||||
|
||||
95 methods, 6,779 lines. Three largest methods total 3,087 lines — 43% of the file.
|
||||
|
||||
| Lines | Location | Method | Branch/loop/try nodes |
|
||||
|---|---|---|---|
|
||||
| 1,675 | `llm.py:2557` | `get_response` | **194** |
|
||||
| 969 | `llm.py:4706` | `get_response_async` | 115 |
|
||||
| 443 | `llm.py:4233` | `get_response_stream` | 56 |
|
||||
| 249 | `llm.py:5966` | `_build_completion_params` | 52 |
|
||||
| 199 | `llm.py:413` | `__init__` | 10 |
|
||||
|
||||
### Adapter call sites — the A1 finding
|
||||
|
||||
`DefaultAdapter` declares **17 methods** (an earlier draft said 18; the 18th item,
|
||||
`add_provider_adapter`, is a module-level function). Eleven methods have **zero** call sites
|
||||
anywhere, and with the three module functions that is 12 dead items. Note also that
|
||||
`LLMProviderAdapterProtocol` declares only **16** — it omits `get_streaming_adapter`, so that
|
||||
method is not part of the protocol it claims to implement.
|
||||
|
||||
```
|
||||
supports_streaming 2
|
||||
format_tool_result_message 1 (llm.py:1712)
|
||||
get_default_settings 1 (llm.py:690)
|
||||
should_summarize_tools 1
|
||||
supports_prompt_caching 1
|
||||
supports_streaming_with_tools 1
|
||||
--- zero call sites ---
|
||||
recover_tool_calls_from_text 0 (inline copy at llm.py:3468-3500)
|
||||
parse_tool_calls 0
|
||||
handle_empty_response_with_tools 0
|
||||
post_tool_iteration 0
|
||||
format_tools 0
|
||||
get_streaming_adapter 0
|
||||
get_max_iteration_threshold 0
|
||||
should_skip_streaming_with_tools 0
|
||||
supports_structured_output 0
|
||||
inject_cache_control 0
|
||||
extract_reasoning_tokens 0
|
||||
add_provider_adapter 0 (public registration hook)
|
||||
```
|
||||
|
||||
Ollama references (case-insensitive): `llm/llm.py` **145**, `llm/adapters/__init__.py` 19,
|
||||
`llm/streaming_protocol.py` 11, `tools/call_executor.py` 6.
|
||||
|
||||
### Compensation coverage across the three response paths — the A2 finding
|
||||
|
||||
```
|
||||
get_response _async _stream
|
||||
1675 L 969 L 443 L
|
||||
tool_result_mapping (value fix-up) 8 0 0
|
||||
max_tool_repairs (repair loop) 2 0 0
|
||||
force_tool_usage (prompt inject) 1 0 0
|
||||
_validate_and_filter_ollama_arguments 1 1 0
|
||||
_generate_ollama_tool_summary 1 1 0
|
||||
_handle_ollama_sequential_logic 1 1 0
|
||||
_format_ollama_tool_result_message 1 1 1* (indirect)
|
||||
```
|
||||
|
||||
`get_response_stream` has none of the seven — except `_format_ollama_tool_result_message`,
|
||||
which it reaches indirectly via `_create_tool_message` (`llm.py:6791`), so that row overstated
|
||||
the gap. A fuller re-measurement found **16** divergent compensations, not 7; three of them run
|
||||
*against* the sync path. The full matrix is in `06-adapter-revival.md` §2.6. `difflib` similarity of `get_response` against
|
||||
`get_response_async` is **22.3%**, while the file's other four sync/async pairs are 76–91% —
|
||||
so those two have diverged, not merely duplicated.
|
||||
|
||||
### Import graph — the I6 constraint
|
||||
|
||||
65 subpackages, 212 directed edges, **26 mutually-importing pairs**. Heaviest:
|
||||
|
||||
```
|
||||
agent <-> tools 53 + 1 llm/ imports: tools 10, streaming 7,
|
||||
agent <-> approval 28 + 1 agent 4, telemetry 2,
|
||||
agent <-> config 25 + 1 auth 2, compaction 2,
|
||||
agent <-> llm 19 + 4 context 1, thinking 1
|
||||
llm <-> tools 10 + 1
|
||||
context <-> llm 2 + 1 15 subpackages import llm/
|
||||
```
|
||||
|
||||
### Reachability — the A3 finding
|
||||
|
||||
21 modules call a provider SDK directly, bypassing both clients: 23 `litellm.completion/acompletion`,
|
||||
7 `litellm.embedding/aembedding`, 16 `chat.completions.create`, 3 bare `OpenAI()`.
|
||||
|
||||
These nine contain **zero** occurrences of `api_base` or `base_url`, so there is no parameter
|
||||
to set — "run everything locally" is currently unachievable, not merely unconfigured:
|
||||
|
||||
```
|
||||
memory/memory.py workflows/workflows.py eval/judge.py
|
||||
eval/grader.py eval/accuracy.py context/compressor.py
|
||||
context/optimizer.py lite/__init__.py telemetry/performance_monitor.py
|
||||
```
|
||||
|
||||
Worst single case, `memory/memory.py:2186-2192`:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI() # no base_url, no api_key
|
||||
response = client.chat.completions.create(
|
||||
model=llm or "gpt-4o-mini", ...
|
||||
```
|
||||
|
||||
Hardcoded defaults in code (excluding docstrings): **105** `"gpt-4o-mini"`, **17**
|
||||
`"text-embedding-3-small"`. Sixteen of the 105 are the identical line
|
||||
`self.llm if hasattr(self,'llm') else "gpt-4o-mini"` in `agent/context_agent.py`.
|
||||
|
||||
### Stringly-typed provider identity — the A6 finding
|
||||
|
||||
**77 occurrences of 23 distinct string predicates** across `src/`. The same provider is
|
||||
tested inconsistently, which is precisely how D3 and D4 arose:
|
||||
|
||||
```
|
||||
9 startswith("anthropic/") gemini tested 3 ways: gpt tested 4 ways:
|
||||
8 startswith("ollama/") startswith("gemini") startswith('gpt-')
|
||||
7 "ollama" in startswith("gemini/") startswith("gpt-")
|
||||
6 startswith("gemini") startswith('gemini-') startswith("gpt")
|
||||
5 startswith("openai/") "gpt" in
|
||||
5 startswith("claude")
|
||||
3 ":11434" in
|
||||
```
|
||||
|
||||
### Accretion history — why boundaries matter here
|
||||
|
||||
| File | First | Now | Commits | Ever shrank |
|
||||
|---|---|---|---|---|
|
||||
| `llm/llm.py` | 1,212 (Jan 2025) | 7,160 | 251 | never, in 18 sampled months |
|
||||
| `agent/agent.py` | 1,251 (Jan 2025) | 7,701 | 472 | once — the split below |
|
||||
| `agent/chat_mixin.py` | created Apr 2026 | 6,151 | 150 | — |
|
||||
|
||||
On **2026-04-01** `agent.py` was split 8,915 → 5,030 by extracting `chat_mixin.py`. Five
|
||||
months later the two halves total **13,852 lines — 55% above the pre-split file**, growing
|
||||
~33 lines/day. Moving code did not create a boundary. This is the argument for I6 being a
|
||||
*test* rather than a convention.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency facts
|
||||
|
||||
| Package | Version present | Status |
|
||||
|---|---|---|
|
||||
| `httpx` | 0.28.1 | **hard transitive dep** of required `openai>=2.0.0` (`httpx<1,>=0.23.0`) |
|
||||
| `aiohttp` | 3.14.1 | direct required core dep |
|
||||
| `openai` | 2.44.0 | direct required core dep |
|
||||
| `litellm` | 1.90.2 | optional (`llm`, `memory` extras) |
|
||||
|
||||
`pyproject.toml` declares **17 optional extras** — `a2ui, api, all, auth, autonomy, crawl,
|
||||
dakera, graph, knowledge, llm, mcp, memory, mongodb, os, sandbox, search, telemetry` — and
|
||||
**none of them is local**. `import praisonaiagents` measures 0.04 s, so imports are lazy and
|
||||
cheap; discovery latency is the only startup cost to manage.
|
||||
|
||||
**Conclusion (superseded).** This section originally concluded `local/` should use `httpx`.
|
||||
That was reversed by the normative decision in `07-local-package-spec.md` §3: `httpx` is present
|
||||
only as an **undeclared transitive** edge (`openai -> httpx`), so building the dependency sink on
|
||||
it means a future `openai` release dropping `httpx` would silently break `local/`. The client is
|
||||
therefore stdlib `urllib.request`, and the boundary test forbids `httpx`/`requests`/`aiohttp`.
|
||||
The measurement above stands; only the recommendation drawn from it was wrong.
|
||||
|
||||
---
|
||||
|
||||
## 5. litellm prefix resolution vs ours
|
||||
|
||||
litellm 1.90.2 resolves all of these; PraisonAI's `_detect_provider` recognises only `ollama/`:
|
||||
|
||||
```
|
||||
ollama/llama3.2 -> ollama OllamaAdapter repairs=2
|
||||
ollama_chat/llama3.2 -> openai DefaultAdapter repairs=0 <- litellm's RECOMMENDED prefix
|
||||
lm_studio/qwen -> openai DefaultAdapter repairs=0
|
||||
vllm/x -> openai DefaultAdapter repairs=0
|
||||
hosted_vllm/x -> openai DefaultAdapter repairs=0
|
||||
huggingface/x -> openai DefaultAdapter repairs=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Known-bad environment note
|
||||
|
||||
`mervinpraison/praisonai-qwen3.5-9b-tamil-en2ta:latest` does **not load** on Ollama 0.33.2:
|
||||
`Failed to load CLIP model from ...sha256-11540ddc21b3...`, HTTP 500. The vision projector
|
||||
blob is the failing piece. Use `qwen3:0.6b` for local verification instead. Unrelated to this
|
||||
work, but it will waste an agent's time if not known.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. The three response paths disagree — measured, not inferred
|
||||
|
||||
Live Ollama, `temperature=0`, identical prompt and tool, two trials, reproducible
|
||||
(script in `06-adapter-revival.md` §2.7):
|
||||
|
||||
```
|
||||
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
|
||||
async calls=1 'Paris: 21C sunny'
|
||||
stream calls=10 ''
|
||||
```
|
||||
|
||||
> **Corrected 2026-09-03.** An earlier draft recorded the sync path returning
|
||||
> `'{\n\n\n}'` with one tool call. That did not reproduce; the figures above did,
|
||||
> across two trials. The finding is unchanged — the paths disagree and the stream
|
||||
> path is broken — but the `'{\n\n\n}'` sample should not be cited.
|
||||
|
||||
Three facts follow, and they reframe the work:
|
||||
|
||||
1. **The best-compensated path produces the worst answer.** `get_response` carries 16 of the 18
|
||||
compensations and calls the tool twice, returning a doubled answer;
|
||||
`get_response_async` carries 8 and returns the correct answer once. More compensation is
|
||||
not the goal — *the same* compensation is.
|
||||
2. **The stream path is not under-compensated, it is broken.** It burns all 10 iterations of
|
||||
`max_iter`, invokes the tool **ten times** for a one-tool question, and yields nothing. That
|
||||
is a cost and side-effect amplifier, not a formatting defect.
|
||||
3. Leading hypothesis for the sync/async split: `OLLAMA_FINAL_ANSWER_PROMPT` (`llm.py:5302`,
|
||||
async only). **Still a hypothesis, not a measurement** — it was not settled during order 06,
|
||||
because the corrected baseline changed what needs explaining.
|
||||
|
||||
## 8. Two test-infrastructure traps
|
||||
|
||||
- **`src/praisonai-agents/tests/` is almost entirely outside CI.** The only tests any workflow
|
||||
runs from it are `tests/smoke/test_subscription_auth.py` and
|
||||
`tests/unit/auth/test_agent_auth_wiring.py`. `tests/unit/test_adapter_registry.py` (38 tests)
|
||||
and all 13 files in `tests/unit/llm/` run **nowhere**. Put new tests in
|
||||
`src/praisonai/tests/unit/llm/`, which is run.
|
||||
- **`test_ollama_tool_reliability.py` tests a copy, not the product.** Its `MockLLM`
|
||||
re-implements `_is_ollama_provider`, `_apply_ollama_defaults`, `_validate_tool_call`,
|
||||
`_should_force_tool_usage` and both prompt templates. It cannot fail in response to any change
|
||||
in `llm.py`. Do not treat it as a safety net.
|
||||
|
||||
|
||||
## 9. Test-gating facts (order 04)
|
||||
|
||||
- **`not network`, not `provider_ollama`, is what deselects the mocked base_url suite.** The gating
|
||||
plugin implies `network` from *any* provider marker (`test_gating.py:235-238`) and every CI
|
||||
expression contains `not network`. Editing the workflows' `-m` strings accomplishes nothing.
|
||||
- `tests/unit/**` is **exempt** from provider auto-detection (`test_gating.py:215`,
|
||||
`test_type != 'unit'`). That is why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI
|
||||
while the integration file does not — and why live tests placed under `tests/unit/` need an
|
||||
**explicit** marker.
|
||||
- `-m provider_ollama` currently selects **32 tests across 5 files**; only 7 are Ollama tests.
|
||||
- **4 of the 7 tests in `test_base_url_api_base_fix.py` fail when actually executed** —
|
||||
`LLMResponseError: 'str' object has no attribute 'choices'`. The mock returns a dict; the
|
||||
tool-calling loop needs an object with `.choices`.
|
||||
- **`tests/test_double_api_fix.py` makes a real network call when forced to run.** `llm.py` routes
|
||||
`gpt-4o-mini` through `litellm.responses` (`llm.py:6434`), not `litellm.completion`, so its patch
|
||||
no longer intercepts (verified: 401 from `api.openai.com/v1/responses`). It is currently masked
|
||||
by an unconditional auto-skip — **anyone who "fixes" the skip without fixing the mock gets live
|
||||
billed calls.**
|
||||
- **`network_guard.py` disables itself** for anything under `/integration/`, `/e2e/`, `/live/`
|
||||
(lines 103-105) and for any `provider_*`-marked test (96-100). The suite's network blocker does
|
||||
not cover the tests most likely to make network calls.
|
||||
- **A live Ollama suite already exists** and has never run:
|
||||
`tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on
|
||||
`PRAISONAI_TEST_OLLAMA`. It is broken against `main` — all 7 pass `verbose=True` to `Agent()`,
|
||||
and two pass `force_tool_usage=` / `max_tool_repairs=`; all three now raise `TypeError`.
|
||||
- **`qwen3:0.6b` is 522 MB** (not ~400 MB) and reports `completion, tools, thinking` — **no
|
||||
vision**. Reliability through `Agent`: default 3/4; `force_tool_usage="always"` + `temperature=0`
|
||||
**6/6**; multi-step arithmetic 0/2 (one hung past 90 s).
|
||||
@@ -1,372 +0,0 @@
|
||||
# 04 — D7: provider markers are assigned per file, so mocked tests are gated as if live
|
||||
|
||||
**Branch:** `fix/gating-per-test-provider-markers`
|
||||
**Measured:** clean worktree at `origin/main` (`2591aa405`), working tree clean.
|
||||
**Owns:** `src/praisonai/tests/_pytest_plugins/test_gating.py`, both `pytest.ini` files,
|
||||
`src/praisonai/tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`,
|
||||
`tests/unit/cli/test_test_command.py`.
|
||||
**Must not edit:** any `praisonaiagents/` source; `.github/workflows/**` (order 05).
|
||||
|
||||
> **The audit's stated root cause was wrong, and the correction changes the fix.**
|
||||
> `provider_ollama` is a red herring. **`not network` deselects all 7 tests on its own**, because
|
||||
> the plugin implies `network` from *any* provider marker and the file also matches
|
||||
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
|
||||
> The fix must be marker-side, not workflow-side. This is why order 04 touches no YAML.
|
||||
|
||||
---
|
||||
|
||||
## 1. Corrections to the audit
|
||||
|
||||
| # | Claim | Verdict |
|
||||
|---|---|---|
|
||||
| 1 | 4 CI marker expressions carry `not provider_ollama` | **True but incomplete** — there is a 5th, non-CI location: `src/praisonai-code/praisonai_code/cli/commands/test.py:67` (`praisonai test --tier main`). |
|
||||
| 2 | 7 tests, all deselected | **Exactly true** (`collected 7 items / 7 deselected / 0 selected`). **Stated cause is wrong** — see the callout above. |
|
||||
| 3 | The file never inspects `litellm.completion` kwargs | **True** (only `mock_completion.assert_called()`). **And worse: 4 of the 7 tests fail outright when executed.** They are not merely toothless, they are broken. |
|
||||
| 4 | `test_double_api_fix.py`: 0 asserts, 34 prints | **True. And worse:** all 3 tests are auto-`skip`ped in every configuration, and when forced to run its `patch('litellm.completion')` no longer intercepts — `llm.py` routes `gpt-4o-mini` through `litellm.responses`, so it makes a **real network call** (verified: 401 from `api.openai.com/v1/responses`). |
|
||||
| 5 | `test_ollama_tool_reliability.py` asserts against MockLLM | **True.** 541 lines, MockLLM at 23–197, 28 tests, all pass. **Quantified:** 2 of the 8 re-implemented methods (`_apply_ollama_defaults`, `_try_parse_tool_call_json`) **do not exist on the real `LLM` at all.** |
|
||||
| 6 | agents-side ollama tests gated only by `OLLAMA_HOST` | **True**, and `test_ollama_fix_openai` is gated only by `OPENAI_API_KEY` — a live **billed** call on a plain `pytest`. Mitigating: no CI workflow runs these two files. |
|
||||
| 7 | No ollama live job | **True for CI**, but the audit **missed that the suite already exists**: `tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on `PRAISONAI_TEST_OLLAMA=1`. It is **broken against current `main`**. See order 05. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproduce before the fix
|
||||
|
||||
```bash
|
||||
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
|
||||
CI_M="not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
|
||||
```
|
||||
|
||||
**R1 — 100% deselected:**
|
||||
```bash
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q --disable-warnings
|
||||
# collected 7 items / 7 deselected / 0 selected
|
||||
```
|
||||
|
||||
**R2 — `provider_ollama` is a red herring.** Rerun R1 with `and not provider_ollama` **removed**:
|
||||
still `7 deselected / 0 selected`. `not network` does it alone.
|
||||
|
||||
**R3 — the whole file is tagged.** With a marker-dump plugin, all seven items carry the identical
|
||||
set `['integration', 'network', 'provider_ollama', 'provider_openai', 'skip']` — including the six
|
||||
that never mention Ollama.
|
||||
|
||||
**R4 — 4 of 7 fail when actually executed:**
|
||||
```bash
|
||||
PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all OPENAI_API_KEY=sk-fake-offline \
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -o addopts="" -q --tb=no -rA
|
||||
# 4 failed, 3 passed
|
||||
```
|
||||
All four fail with `LLMResponseError: ... 'str' object has no attribute 'choices'`. The three that
|
||||
pass are the three that never call `get_response()`.
|
||||
|
||||
**R5 — `-m provider_ollama` selects 4× more than it should:**
|
||||
```
|
||||
7 tests/integration/test_base_url_api_base_fix.py
|
||||
7 tests/integration/test_ollama_tool_calling_live.py
|
||||
11 tests/integration/test_rag_integration.py
|
||||
4 tests/integration/test_setup_integration.py
|
||||
3 tests/test_double_api_fix.py
|
||||
```
|
||||
**32 selected; only 7 are Ollama tests.** This is why order 04 is a hard prerequisite for order 05 —
|
||||
`-m provider_ollama` is currently not a usable selector.
|
||||
|
||||
---
|
||||
|
||||
## 3. Root cause
|
||||
|
||||
`test_gating.py:153-166` scans the **entire file**:
|
||||
|
||||
```python
|
||||
153 def _detect_providers_in_file(filepath: Path) -> Set[str]:
|
||||
154 """Detect which providers are referenced in a test file."""
|
||||
...
|
||||
161 content = _get_file_content(filepath)
|
||||
162 detected = set()
|
||||
163 for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
164 if pattern.search(content):
|
||||
165 detected.add(marker)
|
||||
166 return detected
|
||||
```
|
||||
|
||||
Line 28 is `'provider_ollama': re.compile(r'\b(ollama)\b', re.IGNORECASE)`. One occurrence inside
|
||||
`test_ollama_environment_variable_compatibility` tags all seven items (applied at lines 213–230).
|
||||
|
||||
Note line 215: `if item.fspath and test_type != 'unit'` — **`tests/unit/**` is exempt**, which is
|
||||
why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI while the integration file does not.
|
||||
|
||||
**The second mechanism, which the audit missed**, is lines 235–238:
|
||||
|
||||
```python
|
||||
235 # 3. Add network marker if any provider marker is present
|
||||
236 provider_markers = {m for m in existing_markers if m.startswith('provider_')}
|
||||
237 if provider_markers and 'network' not in existing_markers:
|
||||
238 item.add_marker(pytest.mark.network)
|
||||
```
|
||||
|
||||
A mocked test that merely *mentions* `openai` gets `network`, and every CI expression contains
|
||||
`not network`. Lines 262–267 then convert it into a runtime skip.
|
||||
|
||||
**Sub-defect 3.** Lines 59, 145, 177, 234 are all bare `mock_completion.assert_called()`. Nothing
|
||||
reads `call_args`. Measured: `LLM(model='openai/mistral', base_url='http://localhost:4000',
|
||||
api_key='sk-test').get_response("test", stream=False)` calls `litellm.completion` with
|
||||
`base_url='http://localhost:4000'` and `api_base=None`. **So the file's title is stale** — the real
|
||||
invariant is *`base_url` survives into the call*, not "maps to `api_base`".
|
||||
|
||||
---
|
||||
|
||||
## 4. The change
|
||||
|
||||
**Design decision: AST per-test marking, not file splitting.**
|
||||
|
||||
- *Split the file* — cost cheap, **benefit zero** (measured, R2): the six non-Ollama tests still
|
||||
match `provider_openai`, still get `network`, still get deselected. It also leaves `-m
|
||||
provider_ollama` selecting 32 tests and fixes nothing for the other 14 mixed-provider files.
|
||||
**Rejected.**
|
||||
- *AST per-test marking* — one change in one plugin, fixes the class of problem, and is the
|
||||
prerequisite for order 05's selector to mean anything. **Recommended.**
|
||||
|
||||
### Sub-change 1 — per-test provider detection via AST
|
||||
|
||||
Keep `_detect_providers_in_file` (it now serves only the `network` marker) and add:
|
||||
|
||||
```python
|
||||
def _detect_providers_in_text(text: str) -> Set[str]:
|
||||
"""Return every provider marker whose pattern appears in ``text``."""
|
||||
detected = set()
|
||||
for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
if pattern.search(text):
|
||||
detected.add(marker)
|
||||
return detected
|
||||
|
||||
|
||||
def _build_per_test_provider_map(filepath: Path) -> Optional[Dict[tuple, Set[str]]]:
|
||||
"""Map ``(class_name, func_name)`` -> provider markers for one test file.
|
||||
|
||||
The text considered for a test is that test's own source segment plus its
|
||||
decorators -- not the whole file. Returns ``None`` if the file cannot be
|
||||
parsed, so callers fall back to whole-file rather than under-marking.
|
||||
"""
|
||||
key = str(filepath)
|
||||
if key in _file_provider_cache:
|
||||
return _file_provider_cache[key]
|
||||
result: Optional[Dict[tuple, Set[str]]] = None
|
||||
try:
|
||||
source = _get_file_content(filepath)
|
||||
tree = ast.parse(source)
|
||||
result = {}
|
||||
|
||||
def _visit(node, class_name=None):
|
||||
for child in getattr(node, 'body', ()):
|
||||
if isinstance(child, ast.ClassDef):
|
||||
_visit(child, child.name)
|
||||
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
segment = ast.get_source_segment(source, child) or ""
|
||||
for decorator in child.decorator_list:
|
||||
segment += "\n" + (ast.get_source_segment(source, decorator) or "")
|
||||
result[(class_name, child.name)] = _detect_providers_in_text(segment)
|
||||
|
||||
_visit(tree)
|
||||
except (SyntaxError, ValueError, RecursionError):
|
||||
result = None
|
||||
_file_provider_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def _detect_providers_for_item(item) -> Set[str]:
|
||||
"""Provider markers for a single collected item (per test, not per file)."""
|
||||
filepath = Path(item.fspath)
|
||||
if _is_excluded_path(str(filepath)):
|
||||
return set()
|
||||
per_test = _build_per_test_provider_map(filepath)
|
||||
if per_test is None:
|
||||
return _detect_providers_in_file(filepath)
|
||||
parts = item.nodeid.split("::")[1:]
|
||||
if not parts:
|
||||
return _detect_providers_in_file(filepath)
|
||||
func = parts[-1].split("[")[0]
|
||||
cls = parts[-2] if len(parts) >= 2 else None
|
||||
if (cls, func) in per_test:
|
||||
return set(per_test[(cls, func)])
|
||||
if (None, func) in per_test:
|
||||
return set(per_test[(None, func)])
|
||||
# Dynamically generated item we cannot locate in the AST: stay conservative.
|
||||
return _detect_providers_in_file(filepath)
|
||||
```
|
||||
|
||||
Add `import ast`, a `_file_provider_cache: Dict[str, Optional[Dict[tuple, Set[str]]]] = {}` next to
|
||||
`_file_content_cache` (line 46), and clear it in `pytest_configure` (186) and
|
||||
`pytest_sessionfinish` (294).
|
||||
|
||||
### Sub-change 2 — `offline` opt-out, and pin `network` to the file
|
||||
|
||||
Replace lines 213–230 with a version that (a) honours an explicit `offline` marker, (b) calls
|
||||
`_detect_providers_for_item`, and (c) **keeps `network` whole-file derived**:
|
||||
|
||||
```python
|
||||
offline = 'offline' in existing_markers
|
||||
if not offline:
|
||||
detected_providers = _detect_providers_for_item(item)
|
||||
for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
if pattern.search(item.nodeid):
|
||||
detected_providers.add(marker)
|
||||
for provider in detected_providers:
|
||||
if provider not in existing_markers:
|
||||
item.add_marker(getattr(pytest.mark, provider))
|
||||
# The `network` marker stays WHOLE-FILE derived. Narrowing
|
||||
# the provider markers must not, by itself, un-deselect a
|
||||
# test that is really live -- only `offline` does that.
|
||||
if _detect_providers_in_file(filepath) and 'network' not in existing_markers:
|
||||
item.add_marker(pytest.mark.network)
|
||||
```
|
||||
|
||||
**Why `network` stays file-derived — this is the whole backward-compatibility story and is not
|
||||
optional.** Measured: if per-test narrowing were also allowed to narrow `network`, **48 currently-
|
||||
deselected integration tests would newly select, 25 of them with no `skipif` — they would actually
|
||||
execute**, across nine files including `test_rag_live.py` and `test_model_routing_real.py`, in a
|
||||
suite where the network guard is disabled for everything under `/integration/`
|
||||
(`network_guard.py:103-105`). Pinning `network` to the file makes sub-change 1 provably
|
||||
**selection-neutral** under every existing `-m` expression, while still making `-m provider_ollama`
|
||||
mean what it says.
|
||||
|
||||
Register the marker in both `pytest.ini` files and `tests/conftest.py`:
|
||||
```
|
||||
offline: Fully mocked - opt out of provider/network auto-marking
|
||||
```
|
||||
|
||||
### Sub-change 3 — opt the file in, and make it guard its bug
|
||||
|
||||
Add `pytestmark = pytest.mark.offline` plus a response factory:
|
||||
|
||||
```python
|
||||
def make_completion_response(text="Test response"):
|
||||
"""A litellm.completion stand-in the LLM tool-calling loop can consume.
|
||||
|
||||
A bare dict is NOT sufficient: the loop reads ``.choices[0].message`` and a
|
||||
dict raises ``'str' object has no attribute 'choices'``.
|
||||
"""
|
||||
resp = MagicMock()
|
||||
resp.choices = [MagicMock()]
|
||||
resp.choices[0].message.content = text
|
||||
resp.choices[0].message.tool_calls = None
|
||||
resp.choices[0].finish_reason = "stop"
|
||||
return resp
|
||||
```
|
||||
|
||||
For each of the four broken tests: replace the dict `return_value` with
|
||||
`make_completion_response(...)`, pass `stream=False` to `get_response`, and replace
|
||||
`mock_completion.assert_called()` with a real kwarg assertion:
|
||||
|
||||
```python
|
||||
kwargs = mock_completion.call_args.kwargs
|
||||
assert kwargs['base_url'] == 'http://localhost:4000'
|
||||
assert kwargs['api_key'] == 'sk-test'
|
||||
assert kwargs['model'] == 'openai/mistral'
|
||||
```
|
||||
|
||||
- `test_koboldcpp_specific_scenario` → `kwargs['base_url'] == "http://127.0.0.1:5001/v1"`
|
||||
- `test_litellm_documentation_example_compatibility` → `kwargs['base_url'] == "http://0.0.0.0:4000"`
|
||||
- `test_ollama_environment_variable_compatibility` → `kwargs['model'] == 'ollama/llama2'` **and**
|
||||
`'base_url' not in kwargs` (none was configured; the env path must not inject one)
|
||||
|
||||
All four were run standalone against `main` and pass. Leave the three currently-passing tests alone.
|
||||
|
||||
Update the stale module docstring ("maps base_url to api_base" → "base_url must reach litellm") but
|
||||
**do not rename the test functions** — the nodeids are referenced in issue discussion, and renaming
|
||||
would change what the nodeid scan at plugin lines 224–226 sees.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backward-compatibility contract
|
||||
|
||||
Baseline, measured (`tests/integration/`, CI ignores applied): **290 collected, 187 selected,
|
||||
103 deselected** under the CI marker expression.
|
||||
|
||||
> Caveat for the executing agent: a cold first invocation on a fresh interpreter has been observed
|
||||
> to report `157/260`, apparently an optional-dependency import warming effect. **Run it twice**;
|
||||
> `187/290/103` is stable from the second run on. Take your own baseline before touching anything.
|
||||
|
||||
**Tests whose selection status changes: exactly 7**, all in `test_base_url_api_base_fix.py`, all
|
||||
deselected → selected. Post-fix expectation: **194/290 (96 deselected)**. Nothing else moves in
|
||||
either direction — that is what the file-derived `network` guarantees.
|
||||
|
||||
Intended change under `-m provider_ollama` (32 → 9):
|
||||
|
||||
| file | before | after |
|
||||
|---|---|---|
|
||||
| `test_ollama_tool_calling_live.py` | 7 | 7 |
|
||||
| `test_base_url_api_base_fix.py` | 7 | 0 (`offline`) |
|
||||
| `test_rag_integration.py` | 11 | 1 |
|
||||
| `test_setup_integration.py` | 4 | 0 |
|
||||
| `test_double_api_fix.py` | 3 | 1 |
|
||||
|
||||
**The risk, stated plainly.** Un-deselecting tests turns CI red if any fail. **They do: 4 of the 7
|
||||
fail today** (R4). This is not hypothetical.
|
||||
|
||||
**Mitigation — mandatory, not optional: land the gating fix and the four repairs in the same PR.**
|
||||
Do not merge sub-changes 1+2 with the `pytestmark` but without the repairs. **Quarantining with
|
||||
`xfail` is explicitly rejected** — the repairs are ~10 lines each, verified working, and an `xfail`
|
||||
here would recreate the exact defect being fixed (a test that names a bug and does not guard it).
|
||||
|
||||
**Escape hatch.** If the 7 prove flaky in CI for a reason not reproducible locally, revert
|
||||
**sub-change 3 only**. Selection returns to 187/290/103 and the plugin improvements stay.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add
|
||||
|
||||
In `tests/unit/cli/test_test_command.py` (the existing home of gating-plugin tests, and already in
|
||||
`EXCLUDED_PATHS`, so these cannot be auto-marked by the thing they test), add
|
||||
`class TestProviderMarkerGranularity` with:
|
||||
|
||||
- `test_ollama_does_not_leak_to_sibling_tests` — **THE meta-test.** If this fails, D7 has regressed.
|
||||
- `test_openai_does_not_leak_to_sibling_tests`
|
||||
- `test_plain_test_gets_no_provider_markers`
|
||||
- `test_decorators_are_scanned` — a `@pytest.mark.parametrize("m", ["ollama/llama2"])` must mark it
|
||||
- `test_unparseable_file_falls_back_to_whole_file` — conservative over-marking on a parse failure
|
||||
- `test_network_marker_remains_whole_file_derived` — pins the decision that keeps this change
|
||||
selection-neutral for the 96 still-deselected tests
|
||||
- `test_offline_marker_is_registered` — both `pytest.ini` files, or every use warns
|
||||
|
||||
No new dependency: `ast`, `tmp_path` and `configparser` are stdlib/pytest builtins.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification and acceptance
|
||||
|
||||
```bash
|
||||
# 1. The 7 are now selected
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q
|
||||
# expect: 7 selected, 0 deselected
|
||||
|
||||
# 2. They pass offline, with no keys and no services
|
||||
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u OLLAMA_HOST \
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" -o addopts="" -q
|
||||
# expect: 7 passed
|
||||
|
||||
# 3. Selection neutral everywhere else
|
||||
python -m pytest tests/integration/ $IG -o addopts="" -q --collect-only -m "$CI_M" | tail -1
|
||||
# expect: 194/290 tests collected (96 deselected)
|
||||
|
||||
# 4. `-m provider_ollama` now means Ollama -> 7 + 1 + 1, not 32
|
||||
# 5. Meta-tests pass
|
||||
# 6. The three CI shards reproduced verbatim do not regress
|
||||
# 7. The two pytest.ini marker tables stay in sync
|
||||
```
|
||||
|
||||
**Acceptance gate:** `tests/integration/` collects **194/290 (96 deselected)** — exactly seven more
|
||||
than baseline, all seven from `test_base_url_api_base_fix.py` — **and passes with
|
||||
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `OLLAMA_HOST` all unset and no Ollama server running.**
|
||||
|
||||
**PR title:** `fix(tests): mark provider tests per test function, not per file`
|
||||
|
||||
---
|
||||
|
||||
## 8. Filed for separate PRs
|
||||
|
||||
- **`tests/test_double_api_fix.py`'s mock no longer intercepts.** `llm.py` routes `gpt-4o-mini`
|
||||
through `litellm.responses` (`llm.py:6434`), not `litellm.completion`. Forced to run, it makes a
|
||||
**real OpenAI request** (verified: 401 for `https://api.openai.com/v1/responses`). Today it is
|
||||
masked by an unconditional auto-`skip` — **anyone who "fixes" the skip without fixing the mock
|
||||
gets live billed calls.**
|
||||
- **`network_guard.py` disables itself for `/integration/`, `/e2e/`, `/live/`** (lines 103–105) and
|
||||
for any `provider_*`-marked test (96–100). The suite's network blocker does not cover the tests
|
||||
most likely to make network calls.
|
||||
@@ -1,298 +0,0 @@
|
||||
# 05 — Live local-model CI job
|
||||
|
||||
**Branch:** `feat/ci-ollama-live-job`
|
||||
**Depends on:** order `04` must merge first. Before it, `-m provider_ollama` selects 32 tests
|
||||
across 5 files (25 of which are not Ollama tests) and this job is unbuildable.
|
||||
**Owns:** `.github/workflows/test-optimized.yml`,
|
||||
`src/praisonai/tests/integration/test_ollama_tool_calling_live.py`.
|
||||
**Must not edit:** `test_gating.py` (order 04); the `smoke` / `main` / `openai-live` jobs; anything
|
||||
in `test-core.yml`; `src/praisonai-agents/tests/test_ollama_{fix,async_fix}.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The suite already exists — and is broken
|
||||
|
||||
The audit concluded "no ollama live job, and no live suite". The first half is right; **the second
|
||||
is wrong**. `src/praisonai/tests/integration/test_ollama_tool_calling_live.py` is 230 lines,
|
||||
7 tests, gated at lines 19–22 on `PRAISONAI_TEST_OLLAMA`. It has never run anywhere.
|
||||
|
||||
Measured against `main`, **it cannot run** — three `Agent()` kwargs are now rejected:
|
||||
|
||||
```
|
||||
$ python -c "from praisonaiagents import Agent; Agent(name='C', llm='ollama/qwen3:0.6b', verbose=True)"
|
||||
TypeError: Agent.__init__() got unexpected keyword argument(s): verbose
|
||||
verbose: verbosity moved into output=; use output='verbose' (or output=OutputConfig(verbose=True)).
|
||||
|
||||
$ ... Agent(..., force_tool_usage="always") -> TypeError: unexpected keyword argument(s): force_tool_usage
|
||||
$ ... Agent(..., max_tool_repairs=3) -> TypeError: unexpected keyword argument(s): max_tool_repairs
|
||||
```
|
||||
|
||||
All 7 tests use `verbose=True`; two also use the other two. **Every test errors on construction.**
|
||||
The modern form, verified working:
|
||||
|
||||
```python
|
||||
Agent(name="C",
|
||||
llm={"model": "ollama/qwen3:0.6b", "force_tool_usage": "always", "max_tool_repairs": 3},
|
||||
tools=[calculator])
|
||||
# -> llm_instance.force_tool_usage == 'always', llm_instance.max_tool_repairs == 3
|
||||
```
|
||||
|
||||
So this order is **"fix a dead test file and wire it up"**, not "write a live suite".
|
||||
|
||||
---
|
||||
|
||||
## 2. Model selection
|
||||
|
||||
Measured on a real Ollama 0.33.2 host:
|
||||
|
||||
```
|
||||
$ ollama list
|
||||
qwen3:0.6b 7df6b6e09427 522 MB
|
||||
$ ollama show qwen3:0.6b
|
||||
parameters 751.63M quantization Q4_K_M context length 40960
|
||||
Capabilities: completion, tools, thinking
|
||||
```
|
||||
|
||||
**Two corrections:** the blob is **522 MB**, not ~400 MB; and it is **not vision capable** —
|
||||
`completion, tools, thinking` only. Tools + thinking is all this job needs.
|
||||
|
||||
**Reliability, measured end-to-end through `praisonaiagents.Agent`:**
|
||||
|
||||
| configuration | trials | tool invoked + correct answer | latency |
|
||||
|---|---|---|---|
|
||||
| default | 4 | **3/4** — one run returned `{}` with no tool call | 0.5–5.4 s |
|
||||
| `force_tool_usage="always"`, `temperature=0` | 6 | **6/6** | 0.4–4.0 s |
|
||||
| indirect prompt ("I know it's easy, but use the tool") | 1 | 0/1 — emitted `{"type": "calculator", ...}`, wrong key | 0.6 s |
|
||||
| multi-step `(17+25)+(8+9)` | 2 | 1 hung past 90 s; 1 answered 42 instead of 59 | — |
|
||||
|
||||
**Conclusion: `qwen3:0.6b` is the right model, but only the forced, zero-temperature, single-call
|
||||
prompt is CI-deterministic.**
|
||||
|
||||
Rejected alternatives: `qwen2.5:0.5b` (~400 MB, older tool calling, unmeasured — no reason to
|
||||
prefer it over a verified model); `llama3.2:1b` (~1.3 GB, 2.5× the cache for no measured gain);
|
||||
`smollm2:135m` (**no `tools` capability — disqualifying**); `olmo-3` (the file's current default,
|
||||
~7B, far too large per run). Keep `olmo-3` as the *local* default so developer workflow is
|
||||
unchanged; let CI override via env var.
|
||||
|
||||
**Cache:** `~/.ollama/models`, keyed `${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}`.
|
||||
**No `restore-keys`** — a partial restore of a *different* model's blobs is worse than a clean
|
||||
pull, and a stale key would silently test the wrong model.
|
||||
|
||||
**Install:** pinned release tarball, not `curl https://ollama.com/install.sh | sh`. The install
|
||||
script tracks latest, so the job would silently change what it tests, and it pulls GPU runtimes a
|
||||
CPU-only runner never uses. Expect ~20–60 s tarball + ~30–90 s cold model pull, near-zero on a
|
||||
cache hit — **the executing agent must confirm real timings on the first green run and write them
|
||||
into a YAML comment.**
|
||||
|
||||
**Gating:** `if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' ||
|
||||
github.event.inputs.tier == 'nightly'` — byte-identical to `extended-anthropic` (line 467),
|
||||
`extended-google` (525), `extended-groq` (585), `extended-xai` (644), `extended-cohere` (703).
|
||||
**It never runs on a pull request.**
|
||||
|
||||
---
|
||||
|
||||
## 3. One deliberate departure from the sibling jobs
|
||||
|
||||
Every existing live job ends `|| echo "... tests completed"`, so **it can never fail**. That is
|
||||
defensible for key-dependent jobs — a rotated secret should not page anyone — but it is exactly
|
||||
the "test that cannot fail" pattern this whole effort exists to remove, and **Ollama needs no
|
||||
secret**: availability is fully under the job's control.
|
||||
|
||||
So the job has **two run steps**:
|
||||
1. a **required** minimum-contract step with **no `|| echo`**;
|
||||
2. an **informational** step for the measured-flaky remainder, keeping the sibling style.
|
||||
|
||||
**Do not merge them.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Test-file changes
|
||||
|
||||
1. After `pytestmark`, add the model indirection:
|
||||
```python
|
||||
# CI overrides this with a small, fast model; local runs keep olmo-3.
|
||||
OLLAMA_MODEL = "ollama/" + os.getenv("PRAISONAI_OLLAMA_TEST_MODEL", "olmo-3")
|
||||
```
|
||||
2. Replace all 7 hard-coded `llm="ollama/olmo-3"` with `llm=OLLAMA_MODEL`.
|
||||
3. Remove `verbose=True` from all 7 `Agent(...)` calls. Where verbosity is wanted: `output="verbose"`.
|
||||
4. Move `force_tool_usage="always"` and `max_tool_repairs=3` into an `llm` dict.
|
||||
5. Add the CI-minimum test as a new first class — **the only assertion in the file with a measured
|
||||
6/6 pass rate**:
|
||||
|
||||
```python
|
||||
class TestOllamaCIMinimum:
|
||||
"""The minimum contract a local model must satisfy: a tool call round-trips.
|
||||
|
||||
Deliberately narrow. Measured on qwen3:0.6b: this exact configuration
|
||||
(forced tool usage, temperature 0, single arithmetic step) passed 6/6.
|
||||
The default configuration passed 3/4 and multi-step prompts were worse --
|
||||
those live in TestOllamaToolCallingLive, which CI runs informationally.
|
||||
"""
|
||||
|
||||
def test_tool_call_round_trips(self, ollama_available):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
invocations = []
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two integers together.
|
||||
|
||||
Args:
|
||||
a: First number to add
|
||||
b: Second number to add
|
||||
"""
|
||||
invocations.append((a, b))
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="CI Calculator",
|
||||
llm={"model": OLLAMA_MODEL, "force_tool_usage": "always", "temperature": 0},
|
||||
tools=[add],
|
||||
)
|
||||
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
|
||||
|
||||
# 1. the model actually called our function, with the right arguments
|
||||
assert invocations == [(17, 25)], f"tool was not invoked: {invocations!r}"
|
||||
# 2. the tool's return value made it back into the model's answer
|
||||
assert "42" in str(result), f"tool result did not round-trip: {result!r}"
|
||||
```
|
||||
|
||||
Reuse the existing `ollama_available` fixture (lines 48–53) — it already probes
|
||||
`http://localhost:11434/api/tags` and skips cleanly. Do not add a second probe.
|
||||
|
||||
### Why both assertions are needed
|
||||
|
||||
`qwen3:0.6b` answers `17 + 25 = 42` **correctly with no tools at all** (measured, 1.2 s,
|
||||
`tools=[]`). So a bare `assert "42" in result` is satisfied by a model that ignores tools entirely.
|
||||
The `invocations == [(17, 25)]` half is what proves the tool call was emitted, parsed and
|
||||
dispatched with the right arguments; the `"42"` half proves the return value was fed back and
|
||||
reached the final answer. Anything beyond this — multi-step chains, repair loops, resisting
|
||||
distraction — is measured-flaky on a 0.6B model and belongs in the informational step.
|
||||
|
||||
---
|
||||
|
||||
## 5. The YAML
|
||||
|
||||
Insert after `extended-cohere`'s `Skip notice` step (ends line 760), before the `legacy-unit`
|
||||
banner. Key fragments:
|
||||
|
||||
```yaml
|
||||
extended-ollama:
|
||||
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Bump deliberately; the cache key and what we test both follow this.
|
||||
OLLAMA_VERSION: v0.33.2
|
||||
OLLAMA_TEST_MODEL: qwen3:0.6b
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Cache Ollama model blobs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
# No restore-keys: a partial hit from a different model would
|
||||
# silently test something other than OLLAMA_TEST_MODEL.
|
||||
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
|
||||
- name: Install Ollama
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o /tmp/ollama.tgz \
|
||||
"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz"
|
||||
sudo tar -C /usr -xzf /tmp/ollama.tgz
|
||||
ollama --version
|
||||
- name: Start Ollama and pull model
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ollama serve > /tmp/ollama-serve.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
|
||||
echo "Ollama is up after ${i}s"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
|
||||
ollama pull "$OLLAMA_TEST_MODEL"
|
||||
ollama list
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
- name: Run Ollama minimum contract (required)
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
# No "|| echo" here on purpose: no secret is involved, so a failure
|
||||
# here is a real regression in local-model tool calling.
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-m "provider_ollama" -v --tb=short --timeout=120
|
||||
- name: Run remaining Ollama live tests (informational)
|
||||
if: always()
|
||||
env: { ...same... }
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
|
||||
-m "provider_ollama" \
|
||||
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short --timeout=180 \
|
||||
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
|
||||
- name: Ollama server log
|
||||
if: failure()
|
||||
run: tail -100 /tmp/ollama-serve.log
|
||||
```
|
||||
|
||||
And in `test-summary` (line 838): extend `needs: [smoke, main, extended-ollama]` and add the
|
||||
summary rows. `test-summary` is already `if: always()`, so a skipped `extended-ollama` on PRs
|
||||
reports `skipped` rather than blocking.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification and acceptance
|
||||
|
||||
```bash
|
||||
ollama pull qwen3:0.6b
|
||||
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
|
||||
|
||||
# Must be 6/6 before this ships.
|
||||
for i in $(seq 1 6); do
|
||||
PRAISONAI_TEST_OLLAMA=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_LIVE_TESTS=1 \
|
||||
PRAISONAI_TEST_PROVIDERS=ollama PRAISONAI_OLLAMA_TEST_MODEL=qwen3:0.6b \
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-o addopts="" -q --timeout=120 2>&1 | tail -1
|
||||
done
|
||||
|
||||
# The gate still holds with the env var absent:
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py -o addopts="" -q --collect-only
|
||||
# expect: collected, all skipped by the module pytestmark
|
||||
|
||||
# Nothing leaked onto the PR path:
|
||||
grep -n "extended-ollama" -A3 .github/workflows/test-optimized.yml | grep "if:"
|
||||
python -c "import yaml; yaml.safe_load(open('.github/workflows/test-optimized.yml')); print('yaml ok')"
|
||||
|
||||
# Then: gh workflow run "Optimized Test Suite" -f tier=extended
|
||||
```
|
||||
|
||||
**Acceptance gate:** a manual `workflow_dispatch` with `tier=extended` shows `extended-ollama`
|
||||
**green**, with "Run Ollama minimum contract (required)" reporting `1 passed` — **and** the same
|
||||
workflow triggered by a pull request shows `extended-ollama` **skipped**.
|
||||
|
||||
**PR title:** `ci: add nightly live-Ollama job exercising a real local model`
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,196 +0,0 @@
|
||||
# Local Model Layer — implementation ledger
|
||||
|
||||
**Status:** every order below has an open PR. See §4.
|
||||
**Owner workstream:** local model runtimes (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`)
|
||||
**Created:** 2026-09-03
|
||||
|
||||
---
|
||||
|
||||
## 0. How to use this document
|
||||
|
||||
This directory is a **work-order ledger**. It exists so that any agent — including one
|
||||
with no memory of the conversation that produced it — can pick up exactly one unit of
|
||||
work and execute it correctly, in isolation, without re-deriving the analysis.
|
||||
|
||||
**If you are an agent starting work here, do this in order:**
|
||||
|
||||
1. Read [`00-ground-truth.md`](00-ground-truth.md). It records what was measured, how, and
|
||||
when. Do not trust a claim in any work order that contradicts it; re-measure instead.
|
||||
2. Read §2 (Invariants) and §3 (File ownership) of this file. §3 is what stops two agents
|
||||
corrupting each other's work.
|
||||
3. Open the single work order you were assigned. Execute only that one.
|
||||
4. Update the status row in §4 of this file in the same commit as your change. That row is
|
||||
the only shared file content; keep the edit to one line to avoid conflicts.
|
||||
|
||||
**Do not** expand scope, batch two work orders into one PR, or "fix while you're in there".
|
||||
Each order is independently revertible by design and that property is worth more than
|
||||
convenience.
|
||||
|
||||
> **Two corrections this ledger has already had to make.** Both were caught by measuring rather
|
||||
> than reasoning, and both would have sent an agent down the wrong path.
|
||||
>
|
||||
> **The D7 root cause is not `provider_ollama`.** The audit blamed the per-file `provider_ollama`
|
||||
> tag. Measurement (`04-test-gating.md` §2) shows `not network` deselects all seven tests **on its
|
||||
> own**, because the plugin implies `network` from *any* provider marker and the file also matches
|
||||
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
|
||||
> That is why order `04` touches no YAML at all — the fix is marker-side.
|
||||
> Its first draft told an agent to "wire
|
||||
> the twelve dead adapter methods". Measurement (`06-adapter-revival.md` §1) says the opposite:
|
||||
> **12 of 20 items should be deleted, only 3 wired.** Six of the dead methods have no inline
|
||||
> equivalent anywhere — they are speculative API, and an agent told to "wire them up" would
|
||||
> invent an implementation that no test could ever have failed against. Read the decision table
|
||||
> before touching `llm/adapters/`; do not act on the summary in §1 of this file.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this work exists
|
||||
|
||||
Every local runtime worth supporting already speaks `POST /v1/chat/completions`, and
|
||||
`litellm` already handles that transport. So the problem is not calling local models — the
|
||||
SDK can already do that. The problems are:
|
||||
|
||||
- **Discovery.** A user with a running daemon still hand-types a URL, a port and an exact
|
||||
model tag; getting any one wrong produces `OPENAI_API_KEY environment variable is required`.
|
||||
- **Capability.** "Does this model support tools?" is answerable by asking the server
|
||||
(`/api/show`, `/props`, `/server_info`). The SDK asks nothing and guesses from a name prefix.
|
||||
- **Quirks.** Local stacks fail silently with HTTP 200. The sharpest: sending `format` and
|
||||
`tools` together to Ollama suppresses the tool call and the model fabricates an answer.
|
||||
|
||||
The layer being built is therefore a **resolver, not a transport**: it returns data about
|
||||
what is running and what it will get wrong. Compensating behaviour stays in
|
||||
`llm/adapters/`, which already declares methods for exactly this purpose.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariants — never break these
|
||||
|
||||
These come from `src/praisonai-agents/AGENTS.md` §4.6 plus the structural review in
|
||||
`00-ground-truth.md`. Every work order inherits them; none may waive them.
|
||||
|
||||
| # | Invariant | Why it binds this work specifically |
|
||||
|---|-----------|--------------------------------------|
|
||||
| I1 | **Backward compatible.** Public API changes need a deprecation cycle. | Several fixes change which code path a given input takes. Each order carries an explicit list of inputs whose behaviour must not change. |
|
||||
| I2 | **Lazy imports.** Optional deps never imported at module level. | Discovery must not import `litellm` or any runtime SDK at import time. |
|
||||
| I3 | **Safe defaults.** New features are opt-in. | Discovery must not fire unless the model spec is `"local"` or unset. Never probe the network on a fully-specified config. |
|
||||
| I4 | **Deterministic tests.** No dependence on timing or external state. | Every test must pass with **no server running**. Live-server tests go behind an explicit marker and never run in the default suite. |
|
||||
| I5 | **Protocol-driven core.** No heavy implementations in core. | `local/` returns dataclasses. It performs no chat calls, no retry, no request mutation. |
|
||||
| I6 | **`local/` is a dependency sink.** | It may import the **standard library only** — nothing from `praisonaiagents`, and no third-party distribution (`httpx`/`requests`/`aiohttp` are forbidden; use `urllib.request`). Enforced by a test. See §5 and `07` §3. |
|
||||
| I7 | **No second abstraction.** | `llm/adapters/` is the extension point. Do not add a parallel quirk/adapter registry beside it. |
|
||||
| I8 | **No new public knobs without a live consumer.** | Root `AGENTS.md`: do not add params, modules or exports that merely duplicate existing behaviour. |
|
||||
|
||||
### I6 in detail — the import rule
|
||||
|
||||
An AST pass over `praisonaiagents/` (595 files, 238,936 lines) found **65 subpackages, 212
|
||||
directed edges and 26 mutually-importing subpackage pairs**. `llm/` already imports upward
|
||||
into `agent/`, and 15 subpackages import `llm/` back. Consequently a shared helper placed in
|
||||
`llm/` **cannot** be adopted by `memory/` or `eval/` without deepening the existing cycles.
|
||||
|
||||
`local/` is therefore specified as a leaf. That single property is what allows the 21
|
||||
modules which currently bypass the LLM layer to adopt it later. Treat any import of
|
||||
`praisonaiagents.*` from inside `local/` as a build break, not a style issue.
|
||||
|
||||
The HTTP client is stdlib `urllib.request`, **not** `httpx`. An earlier draft of this ledger
|
||||
permitted `httpx` on the grounds that it is a transitive dependency of `openai>=2.0.0`. That
|
||||
was reversed in `07` §3: the edge is undeclared in `pyproject.toml`, so a future `openai`
|
||||
release that drops `httpx` would silently break the sink — precisely the class of failure this
|
||||
package exists to catalogue. The boundary test in §5 hard-forbids `httpx`/`requests`/`aiohttp`
|
||||
so the decision cannot drift.
|
||||
|
||||
---
|
||||
|
||||
## 3. File ownership — collision avoidance
|
||||
|
||||
Two work orders that touch the same file **must not run concurrently**. This table is the
|
||||
authority. Before starting, check that no in-flight PR owns your files.
|
||||
|
||||
| Work order | Owns (may edit) | Must not edit |
|
||||
|---|---|---|
|
||||
| `01-d1-default-model` | `agent/agent.py` (branch chain only) | `llm/llm.py`, `llm/adapters/` |
|
||||
| `02-d3-provider-detection` | `llm/llm.py` (`_detect_provider`, `_is_ollama_provider` only) | `agent/agent.py`, `llm/adapters/` |
|
||||
| `03-d2-base-url-routing` | `agent/agent.py` (base_url branch), root `README.md` | `llm/llm.py`, `llm/adapters/` |
|
||||
| `04-test-gating` | `tests/_pytest_plugins/test_gating.py`, both `pytest.ini`, `tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`, `tests/unit/cli/test_test_command.py` | any `praisonaiagents/` source; **`.github/workflows/**` — the `-m` expressions are not the bug** |
|
||||
| `05-live-ci-job` | `.github/workflows/test-optimized.yml`, `tests/integration/test_ollama_tool_calling_live.py` | any `praisonaiagents/` source; `test_gating.py` |
|
||||
| `06-adapter-revival` | `llm/adapters/__init__.py`, `llm/llm.py` (3 response paths) | `agent/agent.py` |
|
||||
| `07-local-package` | `praisonaiagents/local/**` (new), `src/praisonai/tests/unit/llm/local/**` (new), `pyproject.toml` extras | everything else |
|
||||
| `08-reachability` | `memory/`, `eval/`, `context/`, `lite/`, `telemetry/` | `llm/`, `agent/` |
|
||||
|
||||
### Enforced serialisation
|
||||
|
||||
- **`01` then `03`** — both edit `agent/agent.py`. Land `01` first.
|
||||
- **`02` then `06`** — both edit `llm/llm.py`. Land `02` first; it is far smaller.
|
||||
- **`04` then `05`** — before `04`, `-m provider_ollama` selects 32 tests across 5 files (only 7
|
||||
are Ollama tests), so `05` has no usable selector and is unbuildable.
|
||||
- **`04` and `07` are independent** of orders 01/02/03/06 and may run in parallel with them.
|
||||
- **`08` requires `07`** to have landed, because it consumes `local/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Status
|
||||
|
||||
Update your row in the same commit as your change. One line only.
|
||||
|
||||
Additional PRs not in the original order list, found while executing it:
|
||||
D4 (local route prefixes) #4798, D5 (local embedding dimensions) #4802,
|
||||
D6 (registry accepts local providers) #4800.
|
||||
|
||||
**Three claims in this ledger were corrected by executing it.** Each is marked
|
||||
inline where it appears, but they are collected here because a reader who trusts
|
||||
the summary would otherwise carry the wrong version:
|
||||
|
||||
1. *"Wire the twelve dead adapter methods."* Measurement said the opposite:
|
||||
**12 of 20 items should be deleted, only 3 wired** (`06` §1). Six had no
|
||||
inline equivalent at all — an agent told to wire them would have invented
|
||||
behaviour no test could fail against.
|
||||
2. *"`provider_ollama` deselects the mocked base_url suite."* It does not.
|
||||
**`not network` deselects it on its own**, because the plugin implies
|
||||
`network` from any provider marker (`04` §1). Editing the workflow `-m`
|
||||
strings would have changed nothing.
|
||||
3. *"Running fully locally is unachievable, not merely unconfigured."* Not so:
|
||||
litellm and the openai SDK both honour `OPENAI_BASE_URL`, so the bypass sites
|
||||
**do** reach a local server. The blocker is the hardcoded model name they then
|
||||
ask for (`00` §5, `08`).
|
||||
|
||||
| Order | Title | Branch | PR | Status |
|
||||
|---|---|---|---|---|
|
||||
| 01 | D1 — provider-prefixed default model never reaches litellm | `fix/d1-default-provider-model` | #4795 | PR open |
|
||||
| 02 | D3 — provider over-detection from base_url | `fix/d3-provider-detection` | #4797 | PR open |
|
||||
| 03 | D2 — README recipe + local-endpoint diagnostic | `fix/d2-base-url-routing` | #4799 | PR open |
|
||||
| 04 | D7 — per-file provider marker deselects unrelated tests | `fix/d7-per-test-provider-markers` | #4801 | PR open |
|
||||
| 05 | Live local-model CI job | `feat/ci-ollama-live-job` | #4803 | PR open |
|
||||
| 06 | A1/A2 — make the provider adapter load-bearing (6 sub-steps) | 6 branches, stacked | #4804 #4806 #4808 #4809 #4810 #4811 | **complete — KNOWN_DEAD empty** |
|
||||
| 07 | `praisonaiagents/local/` — discovery and capability resolver | `feat/local-model-resolver` | #4807 | PR open |
|
||||
| 08 | A3 — auxiliary-model fallback configurable everywhere | `fix/reachability-local-endpoint` | #4812 | PR open (model half; parameter half deferred) |
|
||||
|
||||
---
|
||||
|
||||
## 5. The two tests that keep this work honest
|
||||
|
||||
Both are cheap, both are anti-regression, and both should land with their respective orders.
|
||||
|
||||
**Import boundary (`07`).** Asserts `local/` imports nothing from `praisonaiagents`. Without
|
||||
it, I6 decays silently and the leaf property — the whole reason the package can be adopted
|
||||
by `memory/` and `eval/` — is lost.
|
||||
|
||||
**Adapter reachability (`06`).** Asserts every method on `DefaultAdapter` has at least one
|
||||
call site. This is the test whose absence allowed 11 of 17 adapter methods to become dead code
|
||||
while `llm.py` grew 145 hand-written Ollama references. Two limits, both real: it cannot detect
|
||||
an *unreachable* call site (proven by `should_skip_streaming_with_tools`, which a caller would
|
||||
have satisfied while the branch stayed dead), and **one call site satisfies it — so it does not
|
||||
detect the three-path divergence at all.** A floor, not a ceiling.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deliberately out of scope
|
||||
|
||||
Named here so that deferring them is a decision rather than an oversight. Do not let a work
|
||||
order quietly grow into one of these.
|
||||
|
||||
| Deferred | Measured size | Why not now |
|
||||
|---|---|---|
|
||||
| Consolidating `LLM` and `OpenAIClient` behind one protocol | 7,160 + 2,691 lines, selected by one boolean at 6 sites | Neither is a superset of the other; the merge is larger than every order in this ledger combined. |
|
||||
| Decomposing `LLM.get_response` | 1,675 lines, 194 branch/loop/try nodes | Highest-risk file in the package. Order `06` must edit it surgically without reflowing it. |
|
||||
| Removing the sync/async divergence | ~1,628 duplicated lines across 10 pairs; `get_response` vs `_async` only 22.3% similar | Requires the consolidation above to be worth doing. |
|
||||
|
||||
The one obligation this ledger accepts toward them: **do not make them worse.** No order may
|
||||
add a fifth copy of any compensation, and no order may add new logic inside `get_response`
|
||||
that is not a delegation to an adapter.
|
||||
@@ -133,13 +133,6 @@ _LAZY_IMPORTS = {
|
||||
'resolve_harness': ('praisonaiagents.model_harness.profiles', 'resolve_harness'),
|
||||
'register_profile': ('praisonaiagents.model_harness.profiles', 'register_profile'),
|
||||
|
||||
# Test doubles - drive an Agent offline and keep real requests out of a suite
|
||||
'ScriptedModel': ('praisonaiagents.model_harness.scripted', 'ScriptedModel'),
|
||||
'ScriptExhausted': ('praisonaiagents.model_harness.scripted', 'ScriptExhausted'),
|
||||
'allow_model_requests': ('praisonaiagents.model_harness.guard', 'allow_model_requests'),
|
||||
'no_model_requests': ('praisonaiagents.model_harness.guard', 'no_model_requests'),
|
||||
'ModelRequestBlocked': ('praisonaiagents.model_harness.guard', 'ModelRequestBlocked'),
|
||||
|
||||
# Run outcomes - typed validation and agent execution results
|
||||
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
|
||||
'RunStatus': ('praisonaiagents.run_outcome', 'RunStatus'),
|
||||
@@ -227,7 +220,6 @@ _LAZY_IMPORTS = {
|
||||
# Guardrails
|
||||
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
|
||||
'LLMGuardrail': ('praisonaiagents.guardrails', 'LLMGuardrail'),
|
||||
'GuardrailRetry': ('praisonaiagents.guardrails', 'GuardrailRetry'),
|
||||
|
||||
# Approval (agent-centric approval backends)
|
||||
'AutoApproveBackend': ('praisonaiagents.approval.backends', 'AutoApproveBackend'),
|
||||
|
||||
@@ -215,14 +215,6 @@ def __getattr__(name):
|
||||
from .memory_mixin import MemoryMixin
|
||||
_lazy_cache[name] = MemoryMixin
|
||||
return MemoryMixin
|
||||
elif name == 'AttachmentError':
|
||||
from .attachments import AttachmentError
|
||||
_lazy_cache[name] = AttachmentError
|
||||
return AttachmentError
|
||||
elif name == 'build_attachment_parts':
|
||||
from .attachments import build_attachment_parts
|
||||
_lazy_cache[name] = build_attachment_parts
|
||||
return build_attachment_parts
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -305,7 +297,4 @@ __all__ = [
|
||||
'ChatMixin',
|
||||
'ExecutionMixin',
|
||||
'MemoryMixin',
|
||||
# Attachments
|
||||
'AttachmentError',
|
||||
'build_attachment_parts',
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,443 +0,0 @@
|
||||
"""Attachment routing for multimodal prompts.
|
||||
|
||||
The one invariant this module exists to enforce: **an attachment is never
|
||||
dropped silently**. Every input either becomes a model-visible content part, or
|
||||
produces a loud, actionable signal that names the file and says why.
|
||||
|
||||
Two failure classes are treated differently on purpose:
|
||||
|
||||
``raise`` -- *addressing* errors: the path does not exist, is a directory, is
|
||||
unreadable, or the attachment is not a type this API accepts. These are
|
||||
always caller mistakes (a typo, a wrong working directory, a stale path).
|
||||
They are fully actionable at the call site, there is no useful degraded
|
||||
answer, and continuing would produce a confident answer about a file the
|
||||
model never saw. Raising is the only way the caller can tell.
|
||||
|
||||
``warn + visible degrade`` -- *capability* limits: the file is there and
|
||||
readable, but the target model cannot ingest that media type. The run can
|
||||
still be useful, so we log a warning **and** put a marker part in the
|
||||
prompt so the model itself knows a file was withheld or converted and can
|
||||
say so. This is environmental, not a bug in the caller's code, and hard
|
||||
failing would make a model swap a breaking change.
|
||||
|
||||
Escape hatches (both default to the behaviour described above):
|
||||
|
||||
- ``PRAISONAI_ATTACHMENTS_ON_MISSING=warn`` downgrades the missing/unreadable
|
||||
raise to a warning + visible marker part.
|
||||
- ``PRAISONAI_ATTACHMENTS_STRICT=1`` escalates capability degrades to raises,
|
||||
for pipelines that would rather fail than get a partial answer.
|
||||
|
||||
Part shapes emitted (OpenAI/LiteLLM chat-completions style):
|
||||
|
||||
- image -> ``{"type": "image_url", "image_url": {"url": "data:...;base64,..."}}``
|
||||
- pdf native -> ``{"type": "file", "file": {"filename": ..., "file_data": "data:application/pdf;base64,..."}}``
|
||||
- audio -> ``{"type": "input_audio", "input_audio": {"data": "<b64>", "format": "mp3"}}``
|
||||
- text/degraded/withheld -> ``{"type": "text", "text": "[Attachment: ...] ..."}``
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..tools.base import file_part, image_part, text_part
|
||||
|
||||
__all__ = [
|
||||
"AttachmentError",
|
||||
"build_attachment_parts",
|
||||
"resolve_attachment_model_name",
|
||||
"IMAGE_EXTENSIONS",
|
||||
"PDF_EXTENSIONS",
|
||||
"AUDIO_EXTENSIONS",
|
||||
"VIDEO_EXTENSIONS",
|
||||
"TEXT_EXTENSIONS",
|
||||
]
|
||||
|
||||
|
||||
class AttachmentError(ValueError):
|
||||
"""Raised when an attachment cannot be turned into a model content part.
|
||||
|
||||
Subclasses ``ValueError`` so existing ``except ValueError`` handlers around
|
||||
``chat()`` keep working.
|
||||
"""
|
||||
|
||||
|
||||
# --- Type tables -----------------------------------------------------------
|
||||
|
||||
# Unchanged from the original image-only implementation; images must keep
|
||||
# producing byte-identical parts.
|
||||
IMAGE_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
IMAGE_EXTENSIONS = frozenset(IMAGE_MEDIA_TYPES)
|
||||
|
||||
PDF_EXTENSIONS = frozenset({".pdf"})
|
||||
|
||||
# Extension -> the ``format`` string sent in an ``input_audio`` part.
|
||||
AUDIO_FORMATS = {
|
||||
".mp3": "mp3",
|
||||
".wav": "wav",
|
||||
".m4a": "m4a",
|
||||
".ogg": "ogg",
|
||||
".oga": "ogg",
|
||||
".opus": "opus",
|
||||
".flac": "flac",
|
||||
".aac": "aac",
|
||||
}
|
||||
AUDIO_EXTENSIONS = frozenset(AUDIO_FORMATS)
|
||||
|
||||
VIDEO_EXTENSIONS = frozenset({".mp4", ".mov", ".avi", ".mkv", ".webm", ".mpeg", ".mpg", ".wmv"})
|
||||
|
||||
# Files we can safely inline as UTF-8 text. No model capability needed.
|
||||
TEXT_EXTENSIONS = frozenset({
|
||||
".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv", ".json",
|
||||
".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".xml",
|
||||
".html", ".htm", ".sql", ".py", ".js", ".ts", ".tsx", ".jsx", ".java",
|
||||
".c", ".h", ".cpp", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".swift",
|
||||
".kt", ".sh", ".bash", ".zsh", ".env", ".tex", ".srt", ".vtt",
|
||||
})
|
||||
|
||||
# Inlined text is truncated so one large file cannot blow the context window.
|
||||
# Truncation is announced in the part itself, never silent.
|
||||
MAX_INLINE_TEXT_CHARS = 100_000
|
||||
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
|
||||
def _on_missing_raises() -> bool:
|
||||
return os.environ.get("PRAISONAI_ATTACHMENTS_ON_MISSING", "error").strip().lower() not in (
|
||||
"warn", "warning", "ignore", "0", "false",
|
||||
)
|
||||
|
||||
|
||||
def _strict_unsupported() -> bool:
|
||||
return os.environ.get("PRAISONAI_ATTACHMENTS_STRICT", "").strip().lower() in (
|
||||
"1", "true", "yes", "on", "error", "raise",
|
||||
)
|
||||
|
||||
|
||||
def _fail(message: str) -> List[Dict[str, Any]]:
|
||||
"""Addressing error: raise, unless explicitly downgraded to a warning."""
|
||||
if _on_missing_raises():
|
||||
raise AttachmentError(message)
|
||||
logging.warning("Attachment problem: %s", message)
|
||||
return [text_part(f"[Attachment could not be read: {message}]")]
|
||||
|
||||
|
||||
def _degrade(message: str, marker: str) -> List[Dict[str, Any]]:
|
||||
"""Capability limit: warn loudly and leave a marker the model can see."""
|
||||
if _strict_unsupported():
|
||||
raise AttachmentError(message + " (PRAISONAI_ATTACHMENTS_STRICT is set)")
|
||||
logging.warning("Attachment degraded: %s", message)
|
||||
return [text_part(marker)]
|
||||
|
||||
|
||||
def _read_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("utf-8")
|
||||
|
||||
|
||||
def _extract_pdf_text(path: str) -> Optional[str]:
|
||||
"""Return a PDF's text, or ``None`` if no extractor is installed/usable."""
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
from PyPDF2 import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages = []
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
try:
|
||||
pages.append(f"--- page {index} ---\n{page.extract_text() or ''}")
|
||||
except Exception as exc: # noqa: BLE001 - one bad page must not kill the rest
|
||||
logging.debug("PDF page %d of %s failed to extract: %s", index, path, exc)
|
||||
text = "\n".join(pages).strip()
|
||||
return text or None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.debug("PDF text extraction failed for %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _truncate(text: str, label: str) -> str:
|
||||
if len(text) <= MAX_INLINE_TEXT_CHARS:
|
||||
return text
|
||||
logging.warning(
|
||||
"Attachment %s truncated to %d of %d characters", label, MAX_INLINE_TEXT_CHARS, len(text)
|
||||
)
|
||||
return (
|
||||
text[:MAX_INLINE_TEXT_CHARS]
|
||||
+ f"\n\n[... truncated: {len(text) - MAX_INLINE_TEXT_CHARS} more characters not shown ...]"
|
||||
)
|
||||
|
||||
|
||||
def resolve_attachment_model_name(agent: Any) -> str:
|
||||
"""Best-effort model id for capability routing.
|
||||
|
||||
Mirrors the resolution already used by the agent's other capability probes
|
||||
(``_model_supports_web_search`` and friends).
|
||||
"""
|
||||
instance = getattr(agent, "llm_instance", None)
|
||||
model = getattr(instance, "model", None) if instance else None
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
llm = getattr(agent, "llm", None)
|
||||
if isinstance(llm, str) and llm:
|
||||
return llm
|
||||
if isinstance(llm, dict):
|
||||
model = llm.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
return ""
|
||||
|
||||
|
||||
# --- Per-type builders -----------------------------------------------------
|
||||
|
||||
def _image_message_part(path: str, ext: str) -> Dict[str, Any]:
|
||||
"""Encode an image exactly as the original implementation did."""
|
||||
media_type = IMAGE_MEDIA_TYPES.get(ext, "image/jpeg")
|
||||
encoded = _b64(_read_bytes(path))
|
||||
# Canonical part first (shared vocabulary with tools/base.py), then the
|
||||
# provider-facing shape, so there is one definition of "an image part".
|
||||
canonical = image_part(encoded, mime=media_type, name=os.path.basename(path))
|
||||
logging.debug(
|
||||
"Successfully encoded image attachment: %s (%d bytes base64)", path, len(encoded)
|
||||
)
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{canonical['mime']};base64,{canonical['data']}"},
|
||||
}
|
||||
|
||||
|
||||
def _pdf_parts(path: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
if _capability("pdf", model_name):
|
||||
canonical = file_part(
|
||||
_b64(_read_bytes(path)), mime="application/pdf", name=name
|
||||
)
|
||||
return [{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"filename": canonical["name"],
|
||||
"file_data": f"data:{canonical['mime']};base64,{canonical['data']}",
|
||||
},
|
||||
}]
|
||||
|
||||
text = _extract_pdf_text(path)
|
||||
if text:
|
||||
logging.warning(
|
||||
"Model %r cannot accept PDF attachments natively; sending locally "
|
||||
"extracted text of %s instead. Use a PDF-capable model (e.g. an "
|
||||
"Anthropic Claude or OpenAI gpt-4o/4.1 model) to send the document itself.",
|
||||
model_name or "<unknown>", path,
|
||||
)
|
||||
body = _truncate(text, name)
|
||||
return [text_part(
|
||||
f"[Attachment: {name} - PDF text extracted locally because model "
|
||||
f"{model_name or '<unknown>'!r} cannot accept PDF files directly; "
|
||||
f"layout, images and tables are lost]\n{body}"
|
||||
)]
|
||||
|
||||
return _degrade(
|
||||
f"{path}: model {model_name or '<unknown>'!r} cannot accept PDFs natively and the "
|
||||
f"PDF's text could not be extracted (install 'pypdf', or use a PDF-capable model)",
|
||||
f"[Attachment omitted: {name} - the PDF could not be sent to model "
|
||||
f"{model_name or '<unknown>'!r} and its text could not be extracted locally. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _audio_parts(path: str, ext: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
if _capability("audio", model_name):
|
||||
canonical = file_part(
|
||||
_b64(_read_bytes(path)),
|
||||
mime=f"audio/{AUDIO_FORMATS.get(ext, 'mpeg')}",
|
||||
name=name,
|
||||
)
|
||||
return [{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": canonical["data"],
|
||||
"format": AUDIO_FORMATS.get(ext, "mp3"),
|
||||
},
|
||||
}]
|
||||
|
||||
return _degrade(
|
||||
f"{path}: model {model_name or '<unknown>'!r} cannot accept audio input "
|
||||
f"(use an audio-capable model such as gpt-4o-audio-preview or a Gemini model, "
|
||||
f"or transcribe the file first)",
|
||||
f"[Attachment omitted: {name} - audio was not sent because model "
|
||||
f"{model_name or '<unknown>'!r} cannot accept audio input. Answer without "
|
||||
f"it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _text_parts(path: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
except OSError as exc:
|
||||
return _fail(f"{path}: could not be read ({exc})")
|
||||
return [text_part(f"[Attachment: {name}]\n{_truncate(content, name)}")]
|
||||
|
||||
|
||||
def _capability(kind: str, model_name: str) -> bool:
|
||||
"""Capability probe, isolated so a broken/absent litellm never crashes a run."""
|
||||
try:
|
||||
from ..llm.model_capabilities import supports_audio_input, supports_pdf_input
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
try:
|
||||
if kind == "pdf":
|
||||
return bool(supports_pdf_input(model_name))
|
||||
if kind == "audio":
|
||||
return bool(supports_audio_input(model_name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.debug("Capability probe %s/%s failed: %s", kind, model_name, exc)
|
||||
return False
|
||||
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
|
||||
def build_attachment_parts(attachment: Any, model_name: str = "") -> List[Dict[str, Any]]:
|
||||
"""Turn one attachment into zero-or-more model content parts.
|
||||
|
||||
Never returns an empty list for an input it could not handle: it either
|
||||
raises :class:`AttachmentError` or returns a visible marker part.
|
||||
"""
|
||||
# Already-structured content parts pass through untouched.
|
||||
if isinstance(attachment, dict):
|
||||
if "type" not in attachment:
|
||||
logging.warning(
|
||||
"Attachment dict has no 'type' key and may be ignored by the provider: %r",
|
||||
attachment,
|
||||
)
|
||||
return [attachment]
|
||||
|
||||
if isinstance(attachment, os.PathLike):
|
||||
attachment = os.fspath(attachment)
|
||||
|
||||
if not isinstance(attachment, str):
|
||||
raise AttachmentError(
|
||||
f"Unsupported attachment type {type(attachment).__name__!r}: expected a file "
|
||||
f"path, an http(s)/data URL, or a content-part dict, got {attachment!r}"
|
||||
)
|
||||
|
||||
if not attachment.strip():
|
||||
raise AttachmentError("Empty attachment path")
|
||||
|
||||
if attachment.startswith(("http://", "https://", "data:")):
|
||||
return _remote_parts(attachment, model_name)
|
||||
|
||||
if os.path.isdir(attachment):
|
||||
return _fail(f"{attachment}: is a directory, not a file")
|
||||
|
||||
if not os.path.isfile(attachment):
|
||||
return _fail(
|
||||
f"{attachment}: no such file (attachment paths are resolved relative to the "
|
||||
f"current working directory {os.getcwd()!r})"
|
||||
)
|
||||
|
||||
ext = os.path.splitext(attachment)[1].lower()
|
||||
name = os.path.basename(attachment)
|
||||
|
||||
try:
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
return [_image_message_part(attachment, ext)]
|
||||
if ext in PDF_EXTENSIONS:
|
||||
return _pdf_parts(attachment, model_name)
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
return _audio_parts(attachment, ext, model_name)
|
||||
if ext in TEXT_EXTENSIONS:
|
||||
return _text_parts(attachment)
|
||||
except AttachmentError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
return _fail(f"{attachment}: could not be read ({exc})")
|
||||
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
return _degrade(
|
||||
f"{attachment}: video attachments are not supported on the chat-completions "
|
||||
f"path (extract frames as images, or a transcript as text, first)",
|
||||
f"[Attachment omitted: {name} - video attachments are not supported. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
|
||||
# Unknown extension. Rather than guess at a binary, try it as text when it
|
||||
# decodes cleanly as UTF-8; otherwise say plainly that it was not sent.
|
||||
sample_text = _maybe_text_file(attachment)
|
||||
if sample_text is not None:
|
||||
logging.warning(
|
||||
"Attachment %s has an unrecognised extension %r; inlining it as UTF-8 text.",
|
||||
attachment, ext or "(none)",
|
||||
)
|
||||
return [text_part(f"[Attachment: {name} (unrecognised type, sent as text)]\n"
|
||||
f"{_truncate(sample_text, name)}")]
|
||||
|
||||
return _degrade(
|
||||
f"{attachment}: unsupported attachment type {ext or '(no extension)'!r}. Supported: "
|
||||
f"images {sorted(IMAGE_EXTENSIONS)}, PDF, audio {sorted(AUDIO_EXTENSIONS)}, and "
|
||||
f"text-like files. Convert the file, or pass its text instead.",
|
||||
f"[Attachment omitted: {name} - unsupported file type "
|
||||
f"{ext or '(no extension)'!r}. Answer without it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _maybe_text_file(path: str) -> Optional[str]:
|
||||
"""Return the file's text if it is valid UTF-8 and not obviously binary."""
|
||||
try:
|
||||
raw = _read_bytes(path)
|
||||
except OSError:
|
||||
return None
|
||||
if b"\x00" in raw[:8192]:
|
||||
return None
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _remote_parts(url: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
"""Route an http(s) URL or data URI.
|
||||
|
||||
Images (and anything without a recognisable non-image extension) keep the
|
||||
original behaviour: a straight ``image_url`` part. A URL that plainly names
|
||||
a non-image document is called out instead of being passed off as an image.
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
mime = url[5:].split(";", 1)[0].split(",", 1)[0].lower()
|
||||
if mime and not mime.startswith("image/"):
|
||||
return _degrade(
|
||||
f"data URI with media type {mime!r} is not supported as an attachment; "
|
||||
f"only image data URIs are passed through",
|
||||
f"[Attachment omitted: a {mime} data URI was not sent to the model. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
return [{"type": "image_url", "image_url": {"url": url}}]
|
||||
|
||||
path = url.split("?", 1)[0].split("#", 1)[0]
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext and ext not in IMAGE_EXTENSIONS and (
|
||||
ext in PDF_EXTENSIONS or ext in AUDIO_EXTENSIONS
|
||||
or ext in VIDEO_EXTENSIONS or ext in TEXT_EXTENSIONS
|
||||
):
|
||||
return _degrade(
|
||||
f"{url}: remote {ext} attachments are not fetched (this function performs no "
|
||||
f"network I/O); download the file and pass the local path instead",
|
||||
f"[Attachment omitted: {url} - remote {ext} files are not downloaded "
|
||||
f"automatically. Answer without it and say so.]",
|
||||
)
|
||||
|
||||
return [{"type": "image_url", "image_url": {"url": url}}]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||
@@ -802,32 +776,21 @@ Write the complete compiled report:"""
|
||||
Args:
|
||||
new_model: The new model name to switch to (e.g., "gpt-4o", "claude-3-sonnet")
|
||||
"""
|
||||
# Serialise against an in-flight sync model fallback (which also swaps
|
||||
# self.llm / self._unified_dispatcher) so the two can't interleave.
|
||||
with self._get_model_fallback_lock().sync():
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Drop any cached dispatcher bound to the old model so the next
|
||||
# chat()/achat() rebuilds against new_model. Without this, the
|
||||
# OpenAI path keeps dispatching on the old model even though
|
||||
# self.llm now reads the new name (mirrors _apply_default_llm).
|
||||
if getattr(self, '_unified_dispatcher', None) is not None:
|
||||
self._unified_dispatcher = None
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
self._wire_deferred_history_callback()
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
|
||||
def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Start the agent interactively with verbose output.
|
||||
@@ -1421,27 +1384,10 @@ Write the complete compiled report:"""
|
||||
)
|
||||
if _verdict.get("stuck"):
|
||||
if _verdict.get("level") == "critical":
|
||||
# Async parity with the sync path in tool_execution.py:
|
||||
# route the critical verdict through the unified approval
|
||||
# pipeline (human override / per-project policy) instead
|
||||
# of an unconditional hard stop. Use the native async
|
||||
# helper so an async-only / event-loop-bound approval
|
||||
# backend runs on *this* loop via ``approve_async``,
|
||||
# preserving loop-bound resources — the sync bridge would
|
||||
# otherwise run the backend on a throwaway worker-thread
|
||||
# loop and turn its failure into a spurious denial.
|
||||
_approved = await self._doom_loop_approved_async(
|
||||
function_name, arguments, _verdict,
|
||||
)
|
||||
if _approved:
|
||||
_loop_detection.reset_matching_history(
|
||||
_ld_history, function_name, arguments
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"error": _verdict.get("message", "loop detected"),
|
||||
"loop_blocked": True,
|
||||
}
|
||||
return {
|
||||
"error": _verdict.get("message", "loop detected"),
|
||||
"loop_blocked": True,
|
||||
}
|
||||
elif not getattr(self, '_loop_warned_this_turn', False):
|
||||
self._loop_warned_this_turn = True
|
||||
self._pending_self_correction = (
|
||||
@@ -1622,18 +1568,7 @@ Write the complete compiled report:"""
|
||||
result.get("_praison_retryable") is False):
|
||||
result.pop("_praison_retryable", None)
|
||||
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)
|
||||
|
||||
@@ -1725,40 +1660,11 @@ Write the complete compiled report:"""
|
||||
# deny or a tool-call guardrail. The check is pure/sync (no awaits).
|
||||
check = getattr(self, "_check_tool_policy_and_guardrails", None)
|
||||
if check is not None:
|
||||
# ``tools_override`` is threaded through so a run-scoped tool
|
||||
# list cannot bypass the per-tool guardrails its tools declare.
|
||||
policy_result = check(function_name, arguments, tools_override)
|
||||
policy_result = check(function_name, arguments)
|
||||
if isinstance(policy_result, dict):
|
||||
return policy_result # Error dict
|
||||
_, arguments = policy_result
|
||||
|
||||
# MCP tools first — parity with the sync path (_execute_tool_impl).
|
||||
# Without this an MCP-backed tool call hard-fails via achat()/astart().
|
||||
# Only consult MCP when searching the agent's own tools (no override),
|
||||
# matching the sync path which resolves MCP against self.tools.
|
||||
if tools_override is None:
|
||||
resolve_mcp = getattr(self, "_resolve_mcp_tool_result", None)
|
||||
if resolve_mcp is not None:
|
||||
found, mcp_result = resolve_mcp(function_name, arguments)
|
||||
if found:
|
||||
if inspect.isawaitable(mcp_result):
|
||||
mcp_result = await mcp_result
|
||||
# Normalize MCP transport/timeout failures into a native
|
||||
# error dict — parity with the sync path so an async MCP
|
||||
# timeout classifies as a retryable failure instead of
|
||||
# being handed to the model as a bare "Error: ..." string
|
||||
# that looks like a successful result.
|
||||
normalize_mcp = getattr(self, "_normalize_mcp_result", None)
|
||||
if normalize_mcp is not None:
|
||||
mcp_result = normalize_mcp(mcp_result)
|
||||
# Tool-result guardrail gate — the native path applies this
|
||||
# below, but the MCP branch returns early, so gate here too
|
||||
# or successful MCP output would bypass validation entirely.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
mcp_result = apply_result_guardrails(function_name, mcp_result)
|
||||
return mcp_result
|
||||
|
||||
# Try to find the function in the override tools list first, then agent's tools list.
|
||||
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
|
||||
# aliased decorated tools), plain callable ``__name__``, or class name so async
|
||||
@@ -1777,51 +1683,11 @@ Write the complete compiled report:"""
|
||||
(inspect.isclass(tool) and tool.__name__ == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
# Name self-repair — parity with the sync path. A slightly hallucinated
|
||||
# tool name (e.g. 'WebSearch' for 'web_search') is re-matched instead of
|
||||
# hard-failing. Only over the agent's own active tools (no override).
|
||||
if func is None and tools_override is None:
|
||||
repair = getattr(self, "_self_repair_tool_name", None)
|
||||
if repair is not None:
|
||||
func = repair(function_name)
|
||||
|
||||
|
||||
if func is None:
|
||||
# Corrective, model-readable message with the available tools and a
|
||||
# closest-match hint, mirroring the sync path so the model can retry.
|
||||
available = None
|
||||
if hasattr(self, "_available_active_tool_names"):
|
||||
try:
|
||||
available = self._available_active_tool_names()
|
||||
except Exception:
|
||||
available = None
|
||||
if available is not None:
|
||||
suggestion = None
|
||||
try:
|
||||
import difflib
|
||||
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
|
||||
suggestion = near[0] if near else None
|
||||
except Exception:
|
||||
pass
|
||||
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
||||
error_msg = f"Tool '{function_name}' not found.{hint} Available tools: {available}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "available_tools": available}
|
||||
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
|
||||
@@ -1885,128 +1751,22 @@ Write the complete compiled report:"""
|
||||
call_arguments = dict(arguments)
|
||||
call_arguments["idempotency_key"] = durable_key
|
||||
|
||||
# Establish the injection context (Injected[T] params, including
|
||||
# the cooperative cancel_event) around dispatch so the async path
|
||||
# matches the sync execute_tool path. Without this, a long-running
|
||||
# tool (e.g. shell) executed via achat()/astart() never observes an
|
||||
# interrupt and runs until its own timeout. Built lazily and reused
|
||||
# for both the coroutine and executor branches.
|
||||
from ..tools.injected import with_injection_context
|
||||
_inject_state = None
|
||||
if hasattr(self, "_build_agent_state"):
|
||||
try:
|
||||
_inject_state = self._build_agent_state()
|
||||
except Exception:
|
||||
_inject_state = None
|
||||
|
||||
async def _invoke():
|
||||
# Set the tool-progress channel BEFORE dispatch so the sink
|
||||
# propagates into the executor thread via contextvars, mirroring
|
||||
# the sync execute_tool path.
|
||||
if inspect.iscoroutinefunction(call_target):
|
||||
logging.debug(f"Executing async function: {function_name}")
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
logging.debug(f"Executing sync function in executor: {function_name}")
|
||||
loop = asyncio.get_running_loop()
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
# copy_context_to_callable snapshots the CURRENT context (incl.
|
||||
# the injection contextvar) into the executor thread, so setting
|
||||
# it here propagates cancel_event to the sync tool body too.
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
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
|
||||
@@ -2015,10 +1775,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
|
||||
@@ -2035,38 +1792,7 @@ Write the complete compiled report:"""
|
||||
"_praison_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)
|
||||
|
||||
# Tool-result guardrail gate (protocol-driven) — parity with the
|
||||
# sync path in tool_execution.py. Runs on the raw result before it
|
||||
# re-enters the LLM context so a guardrail can inspect or redact
|
||||
# unsafe tool output. Fail-closed. Zero overhead when unset.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
result = apply_result_guardrails(function_name, result, tools_override)
|
||||
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
|
||||
@@ -2097,17 +1823,6 @@ 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
|
||||
|
||||
@@ -6,26 +6,14 @@ operations. Follows protocol-driven design with zero overhead when not used.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Protocol, Set, runtime_checkable
|
||||
from typing import Optional, Protocol, Set
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = [
|
||||
"InterruptControllerProtocol",
|
||||
"CancellableInterruptControllerProtocol",
|
||||
"InterruptController",
|
||||
]
|
||||
__all__ = ["InterruptControllerProtocol", "InterruptController"]
|
||||
|
||||
|
||||
class InterruptControllerProtocol(Protocol):
|
||||
"""Protocol for interrupt controller extension point.
|
||||
|
||||
``event`` is intentionally NOT part of this base contract: exposing the
|
||||
underlying cancellation Event is an OPTIONAL capability. Existing third-party
|
||||
controllers that only implement request/clear/is_set/reason/check remain
|
||||
valid; the runtime probes for ``event`` via ``getattr(..., 'event', None)``.
|
||||
Controllers that want in-tool cooperative abort can implement
|
||||
:class:`CancellableInterruptControllerProtocol`.
|
||||
"""
|
||||
"""Protocol for interrupt controller extension point."""
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with optional reason."""
|
||||
@@ -43,27 +31,12 @@ class InterruptControllerProtocol(Protocol):
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get interrupt reason if set."""
|
||||
...
|
||||
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for interrupt and raise if set."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancellableInterruptControllerProtocol(InterruptControllerProtocol, Protocol):
|
||||
"""Optional extension: controllers that expose the cancellation Event.
|
||||
|
||||
A running tool body can wait on / poll this Event to abort promptly instead
|
||||
of running until its own timeout. Implementing it is entirely optional and
|
||||
fully backward compatible with :class:`InterruptControllerProtocol`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def event(self) -> "threading.Event":
|
||||
"""Get the underlying cancellation Event for in-tool observation."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptController:
|
||||
"""Thread-safe cooperative cancellation for agent runs.
|
||||
@@ -122,19 +95,10 @@ class InterruptController:
|
||||
return turn_id in self._cancelled_turns
|
||||
|
||||
def _end_turn(self, turn_id: int) -> None:
|
||||
"""Unregister a turn so its request cannot affect a future turn.
|
||||
|
||||
Also clears the underlying Event once no turn still needs it (no active
|
||||
or still-cancelled turns and no pending request). The Event is exposed to
|
||||
running tool bodies via :attr:`event`; leaving it set after an interrupted
|
||||
turn would make a reused controller abort tools launched by the NEXT turn.
|
||||
"""
|
||||
"""Unregister a turn so its request cannot affect a future turn."""
|
||||
with self._lock:
|
||||
self._active_turns.discard(turn_id)
|
||||
self._cancelled_turns.discard(turn_id)
|
||||
if not self._active_turns and not self._cancelled_turns and not self._pending_turn_cancel:
|
||||
self._flag.clear()
|
||||
self._reason = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cancellation request."""
|
||||
@@ -162,16 +126,6 @@ class InterruptController:
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
@property
|
||||
def event(self) -> threading.Event:
|
||||
"""Expose the underlying cancellation Event.
|
||||
|
||||
Lets a running tool body observe the interrupt cooperatively (e.g. wait
|
||||
on it with a timeout, or poll ``is_set()``) so an in-flight subprocess/
|
||||
request can be aborted rather than left running until its own timeout.
|
||||
"""
|
||||
return self._flag
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for cancellation and raise if requested.
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ for maintainability.
|
||||
import os
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
import contextvars
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional
|
||||
|
||||
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
|
||||
# runs in its own thread (sync) or task (async); both get an isolated copy of
|
||||
@@ -22,38 +21,6 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
|
||||
"praisonai_active_turn_owned", default=None
|
||||
)
|
||||
|
||||
# Registry of the ownership lists of every currently in-flight chat()/achat()
|
||||
# turn, keyed by id() of the list. A turn registers its list in
|
||||
# _begin_turn_tracking and removes it in _end_turn_tracking. ephemeral() reads
|
||||
# this at exit so a message a *concurrent live turn* appended is preserved even
|
||||
# when that append did not flow through the ephemeral tracker (e.g. the turn
|
||||
# ran on another thread that never entered this block's context). Guarded by
|
||||
# _live_turns_lock because turns on different threads mutate it concurrently.
|
||||
_live_turn_owners: "Dict[int, list]" = {}
|
||||
_live_turns_lock = threading.Lock()
|
||||
|
||||
|
||||
def _register_live_turn(owned: list) -> None:
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners[id(owned)] = owned
|
||||
|
||||
|
||||
def _unregister_live_turn(owned: Optional[list]) -> None:
|
||||
if owned is None:
|
||||
return
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners.pop(id(owned), None)
|
||||
|
||||
|
||||
def _live_turn_message_ids() -> set:
|
||||
"""ids of every message any currently in-flight turn has appended so far."""
|
||||
with _live_turns_lock:
|
||||
owners = list(_live_turn_owners.values())
|
||||
ids = set()
|
||||
for owned in owners:
|
||||
ids.update(id(m) for m in owned)
|
||||
return ids
|
||||
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
@@ -154,18 +121,11 @@ class MemoryMixin:
|
||||
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
|
||||
context var copy is isolated per turn. Safe to call unconditionally; when
|
||||
a turn opts in, its rollback removes only its own messages by identity.
|
||||
|
||||
The list is also registered in the process-wide live-turn registry so a
|
||||
concurrent ``ephemeral()`` block on another thread can preserve messages
|
||||
this in-flight turn appends (see ``_live_turn_message_ids``).
|
||||
"""
|
||||
owned: list = []
|
||||
_register_live_turn(owned)
|
||||
return _active_turn_owned.set(owned)
|
||||
return _active_turn_owned.set([])
|
||||
|
||||
def _end_turn_tracking(self, token):
|
||||
"""Stop recording for the current turn and drop its ownership list."""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
try:
|
||||
_active_turn_owned.reset(token)
|
||||
except (ValueError, LookupError):
|
||||
@@ -176,10 +136,8 @@ class MemoryMixin:
|
||||
|
||||
Used at the start of an async turn (whose control flow has no single
|
||||
wrapping ``finally``) so a stale list from a completed turn on the same
|
||||
task cannot accumulate messages from unrelated turns. Also unregisters
|
||||
that stale list from the live-turn registry so it cannot linger there.
|
||||
task cannot accumulate messages from unrelated turns.
|
||||
"""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
_active_turn_owned.set(None)
|
||||
|
||||
def _rollback_chat_history_to(self, rollback_length):
|
||||
@@ -204,7 +162,7 @@ class MemoryMixin:
|
||||
"""
|
||||
owned = _active_turn_owned.get()
|
||||
with self._history_lock:
|
||||
if owned is not None:
|
||||
if owned:
|
||||
owned_ids = {id(m) for m in owned}
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) not in owned_ids
|
||||
@@ -242,12 +200,9 @@ class MemoryMixin:
|
||||
Also resets _auto_save_last_index to prevent silent message loss
|
||||
when auto_save is enabled.
|
||||
"""
|
||||
# Hold the same lock every other history mutator takes so a concurrent
|
||||
# _add_to_chat_history append can't land on the list object we discard.
|
||||
with self._history_lock:
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
|
||||
def prune_history(self, keep_last: int = 5) -> int:
|
||||
"""
|
||||
@@ -341,32 +296,16 @@ class MemoryMixin:
|
||||
response = agent.chat("[IMAGE] Analyze this")
|
||||
# After block, history is restored - image NOT persisted
|
||||
"""
|
||||
# Restore by identity, not by a blind ``chat_history = saved_history``.
|
||||
# A message a concurrent chat()/achat() turn on the same shared Agent
|
||||
# commits during the block must survive; only the ephemeral additions go.
|
||||
#
|
||||
# A message is KEPT on exit when it is either:
|
||||
# * present in the pre-block snapshot (it predates the block), or
|
||||
# * owned by a chat()/achat() turn that is still in flight (a live
|
||||
# concurrent turn appended it - preserving it is the fix for the
|
||||
# history-loss race).
|
||||
# Everything else that appeared during the block is an ephemeral addition
|
||||
# and is removed. This also drops direct ``chat_history.append`` writes
|
||||
# made inside the block, which carry no turn ownership. Nested blocks
|
||||
# compose: the inner block removes its own additions first, and the outer
|
||||
# block's snapshot still holds everything that predates it.
|
||||
# Save current history state
|
||||
with self._history_lock:
|
||||
snapshot_ids = {id(m) for m in self.chat_history}
|
||||
|
||||
saved_history = self.chat_history.copy()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
live_ids = _live_turn_message_ids()
|
||||
keep_ids = snapshot_ids | live_ids
|
||||
# Restore history to pre-block state
|
||||
with self._history_lock:
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) in keep_ids
|
||||
]
|
||||
self.chat_history = saved_history
|
||||
|
||||
def _init_db_session(self):
|
||||
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
|
||||
@@ -791,16 +730,11 @@ class MemoryMixin:
|
||||
# Reset counter and emit nudge
|
||||
self._turns_since_nudge = 0
|
||||
|
||||
# Optional tool-work floor (Issue #4864): ``nudge_min_tool_iters`` is a
|
||||
# tunable override, not a hard floor. When it is <= 0 the cadence fires
|
||||
# on conversational (tool-less) turns too, so a chat-only companion bot
|
||||
# still learns; a positive value keeps the "only nudge after real tool
|
||||
# work" behaviour for task-oriented agents.
|
||||
# Optional: only nudge if agent has done meaningful work
|
||||
min_iters = getattr(self._learn_config, 'nudge_min_tool_iters', 3)
|
||||
if min_iters > 0:
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
|
||||
return (
|
||||
"\n\n[System nudge] Review the recent conversation. If you discovered a "
|
||||
|
||||
@@ -113,20 +113,11 @@ def resolve_placement(
|
||||
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)
|
||||
tools_name = _name_of(tools_run_on)
|
||||
|
||||
# The two lookups below feed only the branches guarded on run_on_name /
|
||||
# tools_name being set; the entry-point scan in tool_places() is the
|
||||
# dominant cost of Agent() construction, so skip both on the default
|
||||
# (no-placement) path rather than compute results that get discarded.
|
||||
if run_on_name is not None or tools_name is not None:
|
||||
managed = managed_runtimes()
|
||||
places = tool_places()
|
||||
else:
|
||||
managed = places = ()
|
||||
|
||||
if run_on_name is not None and run_on_name not in managed:
|
||||
if run_on_name in places:
|
||||
raise TypeError(
|
||||
@@ -144,6 +135,7 @@ def resolve_placement(
|
||||
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} "
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -315,12 +300,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 +315,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
|
||||
|
||||
@@ -327,187 +327,6 @@ async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, st
|
||||
)
|
||||
|
||||
|
||||
def _build_handler_context(agents_instance, task):
|
||||
"""Build a lightweight context object for a Task.handler / should_run callable.
|
||||
|
||||
Mirrors the WorkflowContext shape used by the Workflow engine so a handler
|
||||
written for either engine behaves identically.
|
||||
"""
|
||||
variables = dict(getattr(agents_instance, 'variables', None) or {})
|
||||
task_vars = getattr(task, 'variables', None)
|
||||
if task_vars:
|
||||
variables.update(task_vars)
|
||||
try:
|
||||
from ..workflows.workflows import WorkflowContext
|
||||
return WorkflowContext(
|
||||
input=variables.get('input', ''),
|
||||
current_step=getattr(task, 'name', '') or '',
|
||||
variables=variables,
|
||||
)
|
||||
except Exception:
|
||||
return {'variables': variables, 'current_step': getattr(task, 'name', '')}
|
||||
|
||||
|
||||
def _task_has_custom_handler(task):
|
||||
"""A handler-only task has no agent/agent_config but does have a handler."""
|
||||
return (
|
||||
getattr(task, 'handler', None) is not None
|
||||
and getattr(task, 'agent', None) is None
|
||||
and getattr(task, 'agent_config', None) is None
|
||||
)
|
||||
|
||||
|
||||
def _finalize_task_handler_result(agents_instance, task, result):
|
||||
"""Build the TaskOutput/TaskResult from a handler's return value.
|
||||
|
||||
Shared by the sync and async handler executors so both paths store the same
|
||||
output shape and expose ``output_variable`` identically.
|
||||
"""
|
||||
from .protocols import TaskResult
|
||||
|
||||
raw = getattr(result, 'output', result)
|
||||
if raw is None:
|
||||
raw = ""
|
||||
raw = str(raw)
|
||||
|
||||
task_output = TaskOutput(
|
||||
description=task.description or (getattr(task, 'name', '') or ''),
|
||||
summary=(task.description or getattr(task, 'name', '') or '')[:10],
|
||||
raw=raw,
|
||||
agent=getattr(task, 'name', '') or 'handler',
|
||||
output_format="RAW",
|
||||
)
|
||||
task.result = task_output
|
||||
|
||||
output_variable = getattr(task, 'output_variable', None)
|
||||
if output_variable:
|
||||
if getattr(agents_instance, 'variables', None) is None:
|
||||
agents_instance.variables = {}
|
||||
agents_instance.variables[output_variable] = raw
|
||||
|
||||
task.status = "completed"
|
||||
return TaskResult(task_output=task_output, success=True)
|
||||
|
||||
|
||||
def _resolve_coroutine_sync(coro):
|
||||
"""Run a coroutine to completion from a synchronous context.
|
||||
|
||||
``astart`` dispatches sync-marked tasks through the sync ``run_task`` path
|
||||
(in an executor), so an ``async def`` handler still lands here. Rather than
|
||||
stringifying the coroutine, drive it to completion on a private loop.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop in this thread — safe to use asyncio.run.
|
||||
return asyncio.run(coro)
|
||||
|
||||
# A loop is already running in this thread; run the coroutine on a fresh
|
||||
# loop in a worker thread so we don't re-enter the active loop.
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
|
||||
|
||||
def _execute_task_handler(agents_instance, task_id):
|
||||
"""Execute a Task.handler and store the result as the task output.
|
||||
|
||||
Returns a TaskResult, mirroring _process_task_result so the sync/async
|
||||
execute paths can short-circuit before requiring an agent. Coroutine
|
||||
handlers are resolved here too, since ``astart`` runs sync-marked tasks
|
||||
through this synchronous path.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
task = agents_instance.tasks[task_id]
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = task.handler(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = _resolve_coroutine_sync(result)
|
||||
return _finalize_task_handler_result(agents_instance, task, result)
|
||||
|
||||
|
||||
async def _aexecute_task_handler(agents_instance, task_id):
|
||||
"""Async variant of _execute_task_handler that awaits coroutine handlers.
|
||||
|
||||
Mirrors the Workflow engine's async path, which awaits handlers returning a
|
||||
coroutine so an ``async def`` handler runs instead of being stringified.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
task = agents_instance.tasks[task_id]
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = task.handler(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return _finalize_task_handler_result(agents_instance, task, result)
|
||||
|
||||
|
||||
def _record_skipped_task(agents_instance, task):
|
||||
"""Record a stable, empty result for a task gated out by should_run.
|
||||
|
||||
Without this the task would be marked completed with no ``task.result``,
|
||||
which makes ``start``/``astart`` fall through to returning the full results
|
||||
dict when the skipped task is the final one, and leaves callers unable to
|
||||
tell a skip from a genuine empty run.
|
||||
"""
|
||||
task_output = TaskOutput(
|
||||
description=task.description or (getattr(task, 'name', '') or ''),
|
||||
summary=(task.description or getattr(task, 'name', '') or '')[:10],
|
||||
raw="",
|
||||
agent=getattr(task, 'name', '') or 'skipped',
|
||||
output_format="RAW",
|
||||
)
|
||||
task.result = task_output
|
||||
task.status = "completed"
|
||||
|
||||
|
||||
def _task_should_skip(agents_instance, task):
|
||||
"""Return True when a task's should_run gate evaluates falsy.
|
||||
|
||||
Mirrors the Workflow engine's should_run behaviour so conditional tasks are
|
||||
honoured under PraisonAIAgents/AgentTeam as well. Coroutine results from an
|
||||
``async def`` gate are resolved to completion here; the async path uses
|
||||
``_atask_should_skip`` to await them natively.
|
||||
"""
|
||||
should_run = getattr(task, 'should_run', None)
|
||||
if should_run is None:
|
||||
return False
|
||||
try:
|
||||
import asyncio
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = should_run(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
# astart runs sync-marked tasks through this sync gate; resolve the
|
||||
# coroutine rather than dropping it so async gates are still honoured.
|
||||
result = _resolve_coroutine_sync(result)
|
||||
return not result
|
||||
except Exception as e:
|
||||
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _atask_should_skip(agents_instance, task):
|
||||
"""Async variant of _task_should_skip that awaits coroutine gates."""
|
||||
import asyncio
|
||||
|
||||
should_run = getattr(task, 'should_run', None)
|
||||
if should_run is None:
|
||||
return False
|
||||
try:
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = should_run(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return not result
|
||||
except Exception as e:
|
||||
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
"""
|
||||
Build unified execution context for task execution (DRY helper).
|
||||
@@ -712,14 +531,6 @@ def _process_task_result(agents_instance, context, agent_output):
|
||||
|
||||
task.result = task_output
|
||||
|
||||
# Expose the task output under its output_variable so downstream tasks /
|
||||
# should_run gates can read it (mirrors the Workflow engine).
|
||||
output_variable = getattr(task, 'output_variable', None)
|
||||
if output_variable:
|
||||
if getattr(agents_instance, 'variables', None) is None:
|
||||
agents_instance.variables = {}
|
||||
agents_instance.variables[output_variable] = agent_output
|
||||
|
||||
# Fail-closed: when structured output was requested but not produced,
|
||||
# the task did not fulfil its contract. Surface it as a failed result so
|
||||
# callers relying on TaskResult.success (and the retry loop, which uses
|
||||
@@ -1087,13 +898,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
)
|
||||
|
||||
if not agents:
|
||||
# Handler-only tasks (task.handler with no agent) don't need an agent,
|
||||
# mirroring the Workflow engine. Allow a team built solely from them.
|
||||
has_handler_only_task = any(
|
||||
_task_has_custom_handler(t) for t in (tasks or [])
|
||||
)
|
||||
if not has_handler_only_task:
|
||||
raise ValueError("At least one agent must be provided")
|
||||
raise ValueError("At least one agent must be provided")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Core initialization
|
||||
@@ -1160,10 +965,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self._completion_events: List[SubAgentCompletionEvent] = []
|
||||
self._event_bus: Optional[EventBus] = None
|
||||
self._spawn_lock = threading.RLock() # Thread-safe spawn operations (reentrant)
|
||||
# Strong references to fire-and-forget async spawn tasks. The event loop
|
||||
# keeps only a weak reference to tasks from asyncio.create_task(), so
|
||||
# without this a running sub-agent task can be garbage-collected mid-run.
|
||||
self._bg_spawn_tasks: set = set()
|
||||
self._team_id = str(uuid.uuid4()) # Unique team identifier
|
||||
# Aggregate stream emitter (lazy). Fans in member agents' per-step
|
||||
# StreamEventEmitter events, tagging each with the emitting agent's id,
|
||||
@@ -1457,13 +1258,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Handler-only tasks (no agent/agent_config) run their custom callable
|
||||
# instead of an LLM, mirroring the Workflow engine. Await here so an
|
||||
# ``async def`` handler actually runs instead of being stringified.
|
||||
if _task_has_custom_handler(task):
|
||||
task_result = await _aexecute_task_handler(self, task_id)
|
||||
return task_result.task_output
|
||||
|
||||
# Initialize memory asynchronously to avoid blocking the event loop on
|
||||
# synchronous Memory() construction. The shared helper's own
|
||||
# `if not task.memory:` guard makes this a safe no-op for the sync path.
|
||||
@@ -1509,15 +1303,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
# Carry the reason into the re-run. Without this the whole-task
|
||||
# retry re-asks the identical prompt and the model has no way to
|
||||
# know what was wrong; Process._build_task_context turns this
|
||||
# dict into "Previous attempt failed validation with reason: ...".
|
||||
task.validation_feedback = {
|
||||
'validation_response': guardrail_result.error,
|
||||
'validated_task': task.name or task.description,
|
||||
'rejected_output': getattr(task_output, 'raw', str(task_output)),
|
||||
}
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
return task_output, True # Signal retry needed
|
||||
|
||||
@@ -1553,28 +1338,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.
|
||||
|
||||
@@ -1646,12 +1409,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Call on_task_start callback and propagate variables (async-aware, mirrors run_task)
|
||||
await self._arun_task_start_hook(task, task_id)
|
||||
|
||||
# Honour the should_run conditional gate (mirrors the Workflow engine).
|
||||
if await _atask_should_skip(self, task):
|
||||
logger.info(f"Task {task_id} skipped by should_run gate")
|
||||
_record_skipped_task(self, task)
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
@@ -1660,9 +1417,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
|
||||
@@ -1790,20 +1546,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
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(
|
||||
@@ -1826,9 +1568,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
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):
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
@@ -1867,9 +1608,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
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):
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Collect async tasks to run in parallel
|
||||
@@ -1878,9 +1618,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# 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 self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -2066,12 +1805,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Handler-only tasks (no agent/agent_config) run their custom callable
|
||||
# instead of an LLM, mirroring the Workflow engine.
|
||||
if _task_has_custom_handler(task):
|
||||
task_result = _execute_task_handler(self, task_id)
|
||||
return task_result.task_output
|
||||
|
||||
# Build execution context using DRY helper
|
||||
context = _build_execution_context(self, task_id)
|
||||
|
||||
@@ -2098,12 +1831,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Call on_task_start callback and propagate variables (shared with arun_task)
|
||||
self._run_task_start_hook(task, task_id)
|
||||
|
||||
# Honour the should_run conditional gate (mirrors the Workflow engine).
|
||||
if _task_should_skip(self, task):
|
||||
logger.info(f"Task {task_id} skipped by should_run gate")
|
||||
_record_skipped_task(self, task)
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
@@ -2843,110 +2570,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
|
||||
@@ -2974,7 +2612,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")
|
||||
@@ -3769,13 +3407,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
This method is safe to call from sync contexts only.
|
||||
"""
|
||||
with self._spawn_lock:
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
# Create unique IDs
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
@@ -3973,19 +3606,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""Async version of spawn_sub_agent using asyncio primitives."""
|
||||
import asyncio
|
||||
|
||||
# Guard shared spawn state with the single threading.RLock used by the
|
||||
# sync path (spawn_sub_agent/announce_completion). An asyncio.Lock would
|
||||
# not exclude the background spawn thread that mutates the same dicts, so
|
||||
# both paths must share one real thread lock. This critical section is
|
||||
# purely synchronous (no await), so holding the thread lock is safe.
|
||||
with self._spawn_lock:
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
# Use asyncio lock for async coordination
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
async with self._async_spawn_lock:
|
||||
# Create unique IDs
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
@@ -4036,14 +3663,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
logger.warning(f"Sub-agent {agent_id} failed: {e}")
|
||||
await self.aannounce_completion(agent_id, task_id, None, success=False, error=str(e))
|
||||
|
||||
# Create task for background execution. Keep a strong reference until the
|
||||
# task finishes; the loop only holds a weak reference, so without this the
|
||||
# sub-agent run could be garbage-collected mid-execution.
|
||||
if not hasattr(self, '_bg_spawn_tasks'):
|
||||
self._bg_spawn_tasks = set()
|
||||
_bg_task = asyncio.create_task(_aexecute_sub_agent())
|
||||
self._bg_spawn_tasks.add(_bg_task)
|
||||
_bg_task.add_done_callback(self._bg_spawn_tasks.discard)
|
||||
# Create task for background execution
|
||||
asyncio.create_task(_aexecute_sub_agent())
|
||||
|
||||
logger.debug(f"Async spawned sub-agent {agent_id} for task {task_id}")
|
||||
return spawned
|
||||
@@ -4058,11 +3679,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of announce_completion."""
|
||||
# Mutate shared completion state under the single threading.RLock shared
|
||||
# with the sync path — an asyncio.Lock would not exclude the background
|
||||
# spawn thread. The awaited publish happens outside the lock so we never
|
||||
# hold a thread lock across an await.
|
||||
with self._spawn_lock:
|
||||
import asyncio
|
||||
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
async with self._async_spawn_lock:
|
||||
# Create completion event
|
||||
completion_event = SubAgentCompletionEvent(
|
||||
agent_id=agent_id,
|
||||
@@ -4076,28 +3698,41 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Store the completion event
|
||||
self._completion_events.append(completion_event)
|
||||
event_bus = self._event_bus
|
||||
|
||||
# Publish completion event via event bus (use async if available)
|
||||
if event_bus:
|
||||
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
if hasattr(event_bus, 'publish_async'):
|
||||
await event_bus.publish_async(event_type, payload)
|
||||
else:
|
||||
# Fallback to sync publish
|
||||
event_bus.publish(event_type, payload)
|
||||
|
||||
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
|
||||
|
||||
# Publish completion event via event bus (use async if available)
|
||||
if self._event_bus:
|
||||
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
|
||||
if hasattr(self._event_bus, 'publish_async'):
|
||||
await self._event_bus.publish_async(
|
||||
event_type,
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Fallback to sync publish
|
||||
self._event_bus.publish(
|
||||
event_type,
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
|
||||
|
||||
async def await_for_completions(
|
||||
self,
|
||||
@@ -4107,22 +3742,16 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""Async, event-driven version of wait_for_completions."""
|
||||
import asyncio
|
||||
|
||||
# Capture the running loop here, in the coroutine's own frame (we ARE the
|
||||
# loop thread). check_completions() may run on a background spawn thread
|
||||
# (spawn_sub_agent publishes synchronously on a plain threading.Thread),
|
||||
# where asyncio.get_running_loop() would raise RuntimeError and the
|
||||
# completion signal would be silently dropped — hanging the awaiter.
|
||||
loop = asyncio.get_running_loop()
|
||||
# Read target agents inside lock to avoid race condition
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
# Read target agents under the single threading.RLock shared with the
|
||||
# sync path so a background spawn thread cannot mutate _spawned_agents
|
||||
# mid-read. These critical sections are synchronous (no await).
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
target_agents = agent_ids or list(self._spawned_agents.keys())
|
||||
|
||||
if not target_agents:
|
||||
# Return existing completions for the requested agents
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
return [e for e in self._completion_events if not agent_ids or e.agent_id in agent_ids]
|
||||
|
||||
# Use asyncio.Event for efficient waiting
|
||||
@@ -4131,54 +3760,45 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
def check_completions():
|
||||
"""Check if all target agents are completed - THREAD-SAFE version."""
|
||||
with self._spawn_lock:
|
||||
events_snapshot = list(self._completion_events)
|
||||
for event in events_snapshot:
|
||||
for event in self._completion_events:
|
||||
if event.agent_id in target_agents:
|
||||
completed_agents.add(event.agent_id)
|
||||
|
||||
if completed_agents >= set(target_agents):
|
||||
# Use call_soon_threadsafe() with the loop captured in the
|
||||
# coroutine's frame above, so this works whether check_completions
|
||||
# runs on the loop thread (initial check) or on a background spawn
|
||||
# thread (synchronous EventBus publish).
|
||||
loop.call_soon_threadsafe(completion_event.set)
|
||||
|
||||
# Subscribe BEFORE the initial state check. If we checked first and then
|
||||
# subscribed, a sub-agent finishing on its background thread in that gap
|
||||
# would append its completion event AND publish it with no subscriber
|
||||
# attached — and the event bus does not replay past events, so the signal
|
||||
# would be lost and the awaiter would hang until timeout (or forever when
|
||||
# timeout is None). completion_handler re-snapshots the full completion
|
||||
# list under the lock, and Event.set() is idempotent, so subscribing
|
||||
# first is safe and simply re-checks state on every published completion.
|
||||
def completion_handler(event):
|
||||
if event.data.get("parent_id") == self._team_id:
|
||||
check_completions()
|
||||
|
||||
subscriber_id = self._event_bus.subscribe(
|
||||
completion_handler,
|
||||
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
|
||||
) if self._event_bus else None
|
||||
|
||||
try:
|
||||
# Check initial state (covers completions that landed before we
|
||||
# subscribed); the subscription covers everything after.
|
||||
check_completions()
|
||||
|
||||
if not completion_event.is_set():
|
||||
# CRITICAL FIX: Use call_soon_threadsafe() to safely set event from background thread
|
||||
try:
|
||||
# Wait for completion or timeout
|
||||
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.call_soon_threadsafe(completion_event.set)
|
||||
except RuntimeError:
|
||||
# Fallback if no event loop running
|
||||
pass
|
||||
finally:
|
||||
# Unsubscribe
|
||||
if subscriber_id and self._event_bus:
|
||||
self._event_bus.unsubscribe(subscriber_id)
|
||||
|
||||
# Check initial state
|
||||
check_completions()
|
||||
|
||||
if not completion_event.is_set():
|
||||
# Subscribe to completion events temporarily
|
||||
def completion_handler(event):
|
||||
if event.data.get("parent_id") == self._team_id:
|
||||
check_completions()
|
||||
|
||||
subscriber_id = self._event_bus.subscribe(
|
||||
completion_handler,
|
||||
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
|
||||
) if self._event_bus else None
|
||||
|
||||
try:
|
||||
# Wait for completion or timeout
|
||||
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
# Unsubscribe
|
||||
if subscriber_id and self._event_bus:
|
||||
self._event_bus.unsubscribe(subscriber_id)
|
||||
|
||||
# Return completed events
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
return [e for e in self._completion_events if e.agent_id in target_agents]
|
||||
|
||||
def __enter__(self):
|
||||
|
||||
@@ -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,35 +80,6 @@ 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.
|
||||
|
||||
@@ -137,22 +108,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(),
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
def _new_approval_id() -> str:
|
||||
@@ -32,20 +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.
|
||||
liveness: Optional zero-arg predicate returning ``True`` while the
|
||||
originating turn is still the current one. ``None``
|
||||
(default) is a no-op — the request is treated as always
|
||||
live, preserving today's behaviour. A backend may consult
|
||||
this at the resolution/execution boundary to drop a
|
||||
resolution that arrives after the turn was superseded or
|
||||
stopped (fail-closed), so a stale approval never fires a
|
||||
tool or delivers a reply for an abandoned turn.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -55,8 +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
|
||||
liveness: Optional[Callable[[], bool]] = 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
|
||||
|
||||
@@ -42,8 +42,6 @@ __all__ = [
|
||||
"BackgroundConfig",
|
||||
# Shared runner accessor
|
||||
"get_background_runner",
|
||||
# Durable background job store
|
||||
"SqliteBackgroundJobStore",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,8 +67,4 @@ def __getattr__(name: str):
|
||||
from .runner import get_background_runner
|
||||
return get_background_runner
|
||||
|
||||
if name == "SqliteBackgroundJobStore":
|
||||
from .sqlite_store import SqliteBackgroundJobStore
|
||||
return SqliteBackgroundJobStore
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -520,47 +520,10 @@ _global_job_manager: Optional[BackgroundJobManager] = None
|
||||
_global_lock = threading.Lock()
|
||||
|
||||
|
||||
def _build_default_store() -> Optional[BackgroundJobStore]:
|
||||
"""Best-effort construct the durable SQLite store for the global manager.
|
||||
|
||||
Persists to ``<runs_dir>/background_jobs.db`` alongside the run ledger so
|
||||
``/tasks`` and background subagents survive a restart. Any failure (missing
|
||||
data dir, read-only filesystem, sqlite error) is swallowed and the manager
|
||||
falls back to pure in-memory behaviour — durability is an enhancement, never
|
||||
a hard requirement. Set ``PRAISONAI_BACKGROUND_JOB_STORE=0`` to opt out.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("PRAISONAI_BACKGROUND_JOB_STORE", "1") in ("0", "false", "False"):
|
||||
return None
|
||||
try:
|
||||
from .sqlite_store import SqliteBackgroundJobStore
|
||||
|
||||
return SqliteBackgroundJobStore()
|
||||
except Exception as e: # noqa: BLE001 — store is optional, never crash startup
|
||||
logger.debug("Durable background job store unavailable (non-fatal): %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_job_manager() -> BackgroundJobManager:
|
||||
"""Get the global job manager instance.
|
||||
|
||||
On first construction a durable :class:`BackgroundJobStore` is attached when
|
||||
one can be built (see :func:`_build_default_store`) and
|
||||
:meth:`BackgroundJobManager.reconcile_on_start` is run once so jobs orphaned
|
||||
by a prior crash are reconciled to ``LOST`` rather than silently lost.
|
||||
"""
|
||||
"""Get the global job manager instance."""
|
||||
global _global_job_manager
|
||||
with _global_lock:
|
||||
if _global_job_manager is None:
|
||||
store = _build_default_store()
|
||||
_global_job_manager = BackgroundJobManager(store=store)
|
||||
if store is not None:
|
||||
try:
|
||||
_global_job_manager.reconcile_on_start()
|
||||
except Exception as e: # noqa: BLE001 — never crash on startup recovery
|
||||
logger.debug(
|
||||
"Background job reconciliation on start failed "
|
||||
"(non-fatal): %s", e,
|
||||
)
|
||||
_global_job_manager = BackgroundJobManager()
|
||||
return _global_job_manager
|
||||
@@ -1,195 +0,0 @@
|
||||
"""
|
||||
SQLite-backed durable store for background jobs.
|
||||
|
||||
A zero-dependency (stdlib ``sqlite3``) implementation of
|
||||
:class:`~praisonaiagents.background.job_manager.BackgroundJobStore`. Jobs are
|
||||
persisted to ``<runs_dir>/background_jobs.db`` by default so they survive a
|
||||
gateway restart; on boot :meth:`BackgroundJobManager.reconcile_on_start` calls
|
||||
:meth:`list_unreconciled` to reconcile jobs left ``PENDING``/``RUNNING`` by a
|
||||
crashed process (marked ``LOST``) and to replay undelivered completed jobs.
|
||||
|
||||
A durable sibling of :class:`~praisonaiagents.runs.sqlite_ledger.SQLiteRunLedger`
|
||||
and :class:`~praisonaiagents.bus.event_log.SqliteEventLog` — same posture:
|
||||
stdlib-only, WAL, a single re-entrant lock guards a shared connection opened
|
||||
with ``check_same_thread=False`` so the runner's worker threads can persist
|
||||
safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
from .._logging import get_logger
|
||||
from .job_manager import JobInfo, JobStatus
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["SqliteBackgroundJobStore"]
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS background_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
origin TEXT,
|
||||
delivered INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_background_jobs_status ON background_jobs(status);
|
||||
"""
|
||||
|
||||
|
||||
def _dumps(value: object) -> Optional[str]:
|
||||
"""Serialise a value to JSON, never failing on non-JSON-native types."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
class SqliteBackgroundJobStore:
|
||||
"""Durable, restart-safe background job store backed by SQLite.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file. Defaults to
|
||||
``<runs_dir>/background_jobs.db``. 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 / "background_jobs.db")
|
||||
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:
|
||||
# Wait (instead of failing fast) when another process/connection
|
||||
# holds a write lock; overlapping gateway processes share one file.
|
||||
self._conn.execute("PRAGMA busy_timeout = 5000")
|
||||
# WAL lets readers and a writer proceed concurrently and improves
|
||||
# crash durability. Skip for in-memory DBs where it is a no-op.
|
||||
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)
|
||||
|
||||
# ── public API (BackgroundJobStore) ───────────────────────────────
|
||||
|
||||
def upsert(self, job: JobInfo) -> None:
|
||||
"""Insert or update the persisted record for ``job`` (keyed by id)."""
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO background_jobs (
|
||||
job_id, status, created_at, started_at, completed_at,
|
||||
result, error, origin, delivered
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(job_id) DO UPDATE SET
|
||||
status=excluded.status,
|
||||
started_at=excluded.started_at,
|
||||
completed_at=excluded.completed_at,
|
||||
result=excluded.result,
|
||||
error=excluded.error,
|
||||
origin=excluded.origin,
|
||||
delivered=excluded.delivered
|
||||
""",
|
||||
(
|
||||
job.job_id,
|
||||
job.status.value,
|
||||
job.created_at,
|
||||
job.started_at,
|
||||
job.completed_at,
|
||||
_dumps(job.result),
|
||||
job.error,
|
||||
_dumps(job.origin) if job.origin else None,
|
||||
1 if job.delivered else 0,
|
||||
),
|
||||
)
|
||||
|
||||
def get(self, job_id: str) -> Optional[JobInfo]:
|
||||
"""Return the persisted :class:`JobInfo`, or ``None`` if unknown."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM background_jobs WHERE job_id = ?", (job_id,)
|
||||
).fetchone()
|
||||
return self._row_to_job(row) if row is not None else None
|
||||
|
||||
def list_unreconciled(self) -> List[JobInfo]:
|
||||
"""Return jobs needing reconciliation after a restart.
|
||||
|
||||
Jobs left ``PENDING``/``RUNNING`` (orphaned by a crash) and jobs
|
||||
``COMPLETED`` with an ``origin`` that were never ``delivered``. Returned
|
||||
oldest-first for deterministic replay.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM background_jobs
|
||||
WHERE status IN (?, ?)
|
||||
OR (status = ? AND delivered = 0 AND origin IS NOT NULL)
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(
|
||||
JobStatus.PENDING.value,
|
||||
JobStatus.RUNNING.value,
|
||||
JobStatus.COMPLETED.value,
|
||||
),
|
||||
).fetchall()
|
||||
return [self._row_to_job(r) for r in rows]
|
||||
|
||||
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 _loads(raw: Optional[str]) -> object:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _row_to_job(cls, row: sqlite3.Row) -> JobInfo:
|
||||
origin = cls._loads(row["origin"])
|
||||
return JobInfo(
|
||||
job_id=row["job_id"],
|
||||
status=JobStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
started_at=row["started_at"],
|
||||
completed_at=row["completed_at"],
|
||||
result=cls._loads(row["result"]),
|
||||
error=row["error"],
|
||||
origin=origin if isinstance(origin, dict) else {},
|
||||
delivered=bool(row["delivered"]),
|
||||
)
|
||||
@@ -41,7 +41,6 @@ from .protocols import (
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
SendStatus,
|
||||
SendErrorKind,
|
||||
classify_send_error,
|
||||
)
|
||||
@@ -76,9 +75,6 @@ from .interactive import (
|
||||
unregister_handler,
|
||||
make_reply_handler,
|
||||
REPLY_NAMESPACE,
|
||||
PollResult,
|
||||
make_poll_result_handler,
|
||||
POLL_NAMESPACE,
|
||||
)
|
||||
from .agent_reply import (
|
||||
AgentReply,
|
||||
@@ -173,7 +169,6 @@ __all__ = [
|
||||
"GatewayAdapterContractError",
|
||||
"BasePlatformAdapter",
|
||||
"SendResult",
|
||||
"SendStatus",
|
||||
"SendErrorKind",
|
||||
"classify_send_error",
|
||||
"InteractiveContext",
|
||||
@@ -188,9 +183,6 @@ __all__ = [
|
||||
"unregister_handler",
|
||||
"make_reply_handler",
|
||||
"REPLY_NAMESPACE",
|
||||
"PollResult",
|
||||
"make_poll_result_handler",
|
||||
"POLL_NAMESPACE",
|
||||
"AgentReply",
|
||||
"TurnCompletion",
|
||||
"extract_presentation",
|
||||
|
||||
@@ -54,23 +54,17 @@ import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .protocols import PlatformCapabilities
|
||||
|
||||
__all__ = [
|
||||
"SendErrorKind",
|
||||
"SendStatus",
|
||||
"SendResult",
|
||||
"classify_send_error",
|
||||
"BasePlatformAdapter",
|
||||
]
|
||||
|
||||
#: Closed set of delivery outcomes for :attr:`SendResult.status`. Exposed as a
|
||||
#: type alias so static callers can verify exhaustive handling of the contract
|
||||
#: instead of treating the outcome as an arbitrary ``str``.
|
||||
SendStatus = Literal["sent", "failed", "queued", "duplicate"]
|
||||
|
||||
|
||||
class SendErrorKind(str, Enum):
|
||||
"""Structured, machine-readable classification of *why* a send failed.
|
||||
@@ -273,10 +267,6 @@ class SendResult:
|
||||
behaviour rather than being silently dropped.
|
||||
retry_after: Suggested seconds to wait before retrying (from the
|
||||
platform's rate-limit response, if provided).
|
||||
queued: True when the send was persisted to a durable outbox for later
|
||||
delivery rather than delivered inline (``status == "queued"``).
|
||||
duplicate: True when a crash-recovered re-send may be a duplicate —
|
||||
an honest at-least-once outcome (``status == "duplicate"``).
|
||||
metadata: Additional platform-specific result details.
|
||||
"""
|
||||
|
||||
@@ -288,32 +278,12 @@ class SendResult:
|
||||
error_kind: Optional[SendErrorKind] = None
|
||||
retryable: bool = True
|
||||
retry_after: Optional[float] = None
|
||||
queued: bool = False
|
||||
duplicate: bool = False
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def status(self) -> SendStatus:
|
||||
"""Closed, branchable delivery outcome for the send contract.
|
||||
|
||||
One of ``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``,
|
||||
derived from the existing flags so a caller of ``BotProtocol``'s
|
||||
``send_message`` can branch on the outcome without exception-handling
|
||||
(a swallowed exception would otherwise be a silent drop). ``queued``
|
||||
(persisted to a durable outbox) and ``duplicate`` (crash-recovered
|
||||
possible re-send) take precedence over the bare ``ok`` flag.
|
||||
"""
|
||||
if self.queued:
|
||||
return "queued"
|
||||
if self.duplicate:
|
||||
return "duplicate"
|
||||
return "sent" if self.ok else "failed"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a plain dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"status": self.status,
|
||||
"message_id": self.message_id,
|
||||
"chat_id": self.chat_id,
|
||||
"message_ids": list(self.message_ids),
|
||||
@@ -321,8 +291,6 @@ class SendResult:
|
||||
"error_kind": self.error_kind.value if self.error_kind else None,
|
||||
"retryable": self.retryable,
|
||||
"retry_after": self.retry_after,
|
||||
"queued": self.queued,
|
||||
"duplicate": self.duplicate,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@@ -473,44 +441,6 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
await self.disconnect()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Identity canonicalization seam. #
|
||||
# #
|
||||
# The gateway keys each conversation on the platform-supplied user id.#
|
||||
# When a platform changes the *native* id for the *same human* #
|
||||
# (WhatsApp JID→LID migration, a renamed handle-as-id, a #
|
||||
# phone-number↔UUID alias flip) the raw id changes and the session #
|
||||
# would silently fork — history/memory/run-state orphaned. This seam #
|
||||
# lets an adapter map a volatile raw id to a stable canonical id #
|
||||
# *before* it becomes part of the session key; the resolver's #
|
||||
# explicit link/pairing lookup then runs on the stabilised id. #
|
||||
# #
|
||||
# It fulfils ``IdentityCanonicalizerProtocol`` (praisonaiagents. #
|
||||
# gateway.protocols) so an adapter *is* its own canonicalizer: pass #
|
||||
# ``self`` where that protocol is accepted. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def canonicalize(self, platform: str, raw_user_id: str) -> str:
|
||||
"""Map a raw, potentially-volatile platform id to a stable canonical id.
|
||||
|
||||
Consulted on the session-key path *before* link/pairing resolution so
|
||||
an alias/format change for the *same* human (e.g. WhatsApp's JID→LID
|
||||
migration, a number↔UUID alias flip) collapses to one stable session
|
||||
key — preserving transcript, memory and in-flight run-state — instead
|
||||
of silently forking the conversation.
|
||||
|
||||
This is the :class:`~praisonaiagents.gateway.protocols.
|
||||
IdentityCanonicalizerProtocol` method, so a ``BasePlatformAdapter``
|
||||
conforms structurally and can be handed anywhere that protocol is
|
||||
accepted.
|
||||
|
||||
Default is the identity function, so adapters that do not override this
|
||||
are fully backward-compatible (the raw id keys the session exactly as
|
||||
before). Implementations MUST be deterministic and total: return the
|
||||
raw id unchanged when no canonical form is known, never raise.
|
||||
"""
|
||||
return raw_user_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Capability helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -659,15 +589,6 @@ class BasePlatformAdapter(ABC):
|
||||
if not result.ok:
|
||||
result.message_ids = aggregate.message_ids + result.message_ids
|
||||
return result
|
||||
# Preserve non-``sent`` outcomes: a chunk persisted to a durable
|
||||
# outbox (``queued``) or crash-recovered as a possible re-send
|
||||
# (``duplicate``) must propagate to the aggregate, otherwise a
|
||||
# deferred/at-least-once delivery would be silently reported as
|
||||
# ``sent`` and the outcome the receipt exists to carry is erased.
|
||||
if result.queued:
|
||||
aggregate.queued = True
|
||||
if result.duplicate:
|
||||
aggregate.duplicate = True
|
||||
if result.message_id:
|
||||
aggregate.message_ids.append(result.message_id)
|
||||
aggregate.message_id = result.message_id
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,101 +135,6 @@ def make_reply_handler(
|
||||
return _handler
|
||||
|
||||
|
||||
POLL_NAMESPACE = "poll"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollResult:
|
||||
"""Aggregated result of a group poll, portable across channels.
|
||||
|
||||
The inbound counterpart of a :class:`PollPresentation`. A channel adapter
|
||||
normalises a native poll-answer/close event (Telegram ``poll``, Discord,
|
||||
Slack) into this shape and routes it through the interactive registry so a
|
||||
workflow can branch on the outcome without knowing the transport.
|
||||
|
||||
Attributes:
|
||||
poll_id: Correlates the result back to the authored poll (the poll's
|
||||
``action_id`` when set, else the channel's native poll id).
|
||||
counts: Mapping of option label -> vote count.
|
||||
total_voters: Number of distinct voters.
|
||||
closed: Whether the poll is closed (final tally).
|
||||
"""
|
||||
|
||||
poll_id: str
|
||||
counts: Dict[str, int]
|
||||
total_voters: int = 0
|
||||
closed: bool = False
|
||||
|
||||
def winner(self) -> Optional[str]:
|
||||
"""Return the option with the most votes, or ``None`` when empty/tied.
|
||||
|
||||
Ties (more than one option sharing the top count) return ``None`` so a
|
||||
caller never silently picks an arbitrary winner.
|
||||
"""
|
||||
if not self.counts:
|
||||
return None
|
||||
top = max(self.counts.values())
|
||||
leaders = [opt for opt, c in self.counts.items() if c == top]
|
||||
return leaders[0] if len(leaders) == 1 else None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"poll_id": self.poll_id,
|
||||
"counts": dict(self.counts),
|
||||
"total_voters": self.total_voters,
|
||||
"closed": self.closed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PollResult":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
poll_id=data.get("poll_id", ""),
|
||||
counts=dict(data.get("counts", {})),
|
||||
total_voters=data.get("total_voters", 0),
|
||||
closed=data.get("closed", False),
|
||||
)
|
||||
|
||||
|
||||
def make_poll_result_handler(
|
||||
on_result: Callable[["PollResult", "InteractiveContext"], Awaitable[Optional[str]]],
|
||||
) -> InteractiveHandler:
|
||||
"""Build a handler for inbound poll results routed to the ``poll`` namespace.
|
||||
|
||||
A channel adapter that receives a native poll-answer/close event normalises
|
||||
it into a :class:`PollResult` and places it on
|
||||
``context.platform_data["poll_result"]`` before dispatching a ``poll:``
|
||||
callback. This handler extracts that result (accepting either a
|
||||
:class:`PollResult` or its ``dict`` form) and forwards it to *on_result*, so
|
||||
a workflow can tally the vote and continue the turn.
|
||||
|
||||
Args:
|
||||
on_result: Async callable ``(result, context) -> Optional[str]`` invoked
|
||||
with the aggregated tally.
|
||||
|
||||
Returns:
|
||||
An ``InteractiveHandler`` to register under the ``poll`` namespace.
|
||||
"""
|
||||
|
||||
async def _handler(context: "InteractiveContext") -> Optional[str]:
|
||||
raw = context.platform_data.get("poll_result")
|
||||
if raw is None:
|
||||
payload = context.platform_data.get("decoded_payload") or {}
|
||||
raw = payload.get("poll_result")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, PollResult):
|
||||
result = raw
|
||||
elif isinstance(raw, dict):
|
||||
result = PollResult.from_dict(raw)
|
||||
else:
|
||||
return None
|
||||
return await on_result(result, context)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def encode_action(namespace: str, action: "PresentationAction") -> str:
|
||||
"""Encode an action with namespace for callback data.
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ class BlockType(str, Enum):
|
||||
CONTEXT = "context" # Contextual info (smaller text)
|
||||
TABLE = "table" # Tabular data (columns + rows)
|
||||
CHART = "chart" # Chart/visualisation (kind + series)
|
||||
POLL = "poll" # Group poll/vote (question + options)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -263,11 +262,6 @@ class PresentationBlock:
|
||||
# Chart data (CHART blocks)
|
||||
chart_kind: Optional[str] = None
|
||||
series: Optional[List[Dict[str, Any]]] = None
|
||||
# Poll data (POLL blocks)
|
||||
poll_options: Optional[List[str]] = None
|
||||
multiple_choice: bool = False
|
||||
anonymous: bool = True
|
||||
duration_seconds: Optional[int] = None
|
||||
|
||||
@staticmethod
|
||||
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
|
||||
@@ -344,53 +338,6 @@ class PresentationBlock:
|
||||
text=text,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_poll(
|
||||
question: str,
|
||||
options: List[str],
|
||||
*,
|
||||
multiple_choice: bool = False,
|
||||
anonymous: bool = True,
|
||||
duration_seconds: Optional[int] = None,
|
||||
action_id: Optional[str] = None,
|
||||
) -> "PresentationBlock":
|
||||
"""Create a poll block for a channel-neutral group vote.
|
||||
|
||||
Args:
|
||||
question: The poll question.
|
||||
options: 2..N answer options.
|
||||
multiple_choice: Allow selecting more than one option.
|
||||
anonymous: Hide who voted for what (native default on most channels).
|
||||
duration_seconds: Optional auto-close duration in seconds.
|
||||
action_id: Optional stable id correlating the inbound
|
||||
:class:`PollResult` back to this poll.
|
||||
|
||||
A channel adapter that implements native poll delivery (e.g. Telegram
|
||||
``sendPoll``, Discord polls) can opt in via
|
||||
``PresentationLimits.supports_native_poll``; the vote is then rendered
|
||||
natively and voting/tallying/closing are handled by the platform.
|
||||
Everywhere else — including channels whose renderer has no poll branch —
|
||||
it degrades to a ``BUTTONS`` grid of reply options via
|
||||
:func:`adapt_presentation`, and the loss is surfaced through
|
||||
:class:`DegradedDelivery` (never silently).
|
||||
|
||||
Raises:
|
||||
ValueError: If fewer than two options are supplied — a poll needs at
|
||||
least two choices to represent a vote.
|
||||
"""
|
||||
opts = [str(o) for o in options]
|
||||
if len(opts) < 2:
|
||||
raise ValueError("A poll requires at least two options")
|
||||
return PresentationBlock(
|
||||
type=BlockType.POLL,
|
||||
text=question,
|
||||
poll_options=opts,
|
||||
multiple_choice=multiple_choice,
|
||||
anonymous=anonymous,
|
||||
duration_seconds=duration_seconds,
|
||||
action_id=action_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def quick_replies(
|
||||
choices: List[Any],
|
||||
@@ -439,12 +386,6 @@ class PresentationBlock:
|
||||
data["chart_kind"] = self.chart_kind
|
||||
if self.series is not None:
|
||||
data["series"] = self.series
|
||||
if self.poll_options is not None:
|
||||
data["poll_options"] = self.poll_options
|
||||
data["multiple_choice"] = self.multiple_choice
|
||||
data["anonymous"] = self.anonymous
|
||||
if self.duration_seconds is not None:
|
||||
data["duration_seconds"] = self.duration_seconds
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
@@ -467,10 +408,6 @@ class PresentationBlock:
|
||||
rows=data.get("rows"),
|
||||
chart_kind=data.get("chart_kind"),
|
||||
series=data.get("series"),
|
||||
poll_options=data.get("poll_options"),
|
||||
multiple_choice=data.get("multiple_choice", False),
|
||||
anonymous=data.get("anonymous", True),
|
||||
duration_seconds=data.get("duration_seconds"),
|
||||
)
|
||||
|
||||
|
||||
@@ -619,7 +556,6 @@ class PresentationLimits:
|
||||
supports_web_apps: Whether channel supports web apps
|
||||
supports_tables: Whether channel has a native table widget
|
||||
supports_charts: Whether channel has native chart/visualisation
|
||||
supports_native_poll: Whether channel has a native poll/vote API
|
||||
max_table_rows: Maximum rows in a table block
|
||||
max_table_cols: Maximum columns in a table block
|
||||
"""
|
||||
@@ -635,7 +571,6 @@ class PresentationLimits:
|
||||
supports_web_apps: bool = False
|
||||
supports_tables: bool = False
|
||||
supports_charts: bool = False
|
||||
supports_native_poll: bool = False
|
||||
max_table_rows: int = 50
|
||||
max_table_cols: int = 10
|
||||
|
||||
@@ -653,7 +588,6 @@ class PresentationLimits:
|
||||
"supports_web_apps": self.supports_web_apps,
|
||||
"supports_tables": self.supports_tables,
|
||||
"supports_charts": self.supports_charts,
|
||||
"supports_native_poll": self.supports_native_poll,
|
||||
"max_table_rows": self.max_table_rows,
|
||||
"max_table_cols": self.max_table_cols,
|
||||
}
|
||||
@@ -673,7 +607,6 @@ class PresentationLimits:
|
||||
supports_web_apps=data.get("supports_web_apps", False),
|
||||
supports_tables=data.get("supports_tables", False),
|
||||
supports_charts=data.get("supports_charts", False),
|
||||
supports_native_poll=data.get("supports_native_poll", False),
|
||||
max_table_rows=data.get("max_table_rows", 50),
|
||||
max_table_cols=data.get("max_table_cols", 10),
|
||||
)
|
||||
@@ -690,12 +623,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=False,
|
||||
supports_web_apps=True,
|
||||
# Native poll delivery (Telegram sendPoll) is not yet implemented in
|
||||
# the praisonai-bot renderer, so keep it disabled: adapt_presentation
|
||||
# degrades polls to the supported question+buttons fallback instead
|
||||
# of the renderer silently dropping an unhandled POLL block. Flip to
|
||||
# True in the same change that adds the renderer's sendPoll branch.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -711,10 +638,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
# Slack's Block Kit has no native poll element, and the renderer has
|
||||
# no POLL branch — keep disabled so polls degrade to question+buttons
|
||||
# rather than being dropped.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -730,11 +653,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
# Discord native poll delivery is not yet implemented in the
|
||||
# praisonai-bot renderer — keep disabled so polls degrade to
|
||||
# question+buttons instead of being dropped. Flip to True in the
|
||||
# same change that adds the renderer's native-poll branch.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -926,32 +844,6 @@ def _select_to_buttons(
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
POLL_NAMESPACE = "poll"
|
||||
|
||||
|
||||
def _poll_to_buttons(
|
||||
block: PresentationBlock,
|
||||
store: Optional["CallbackPayloadStoreProtocol"] = None,
|
||||
) -> PresentationBlock:
|
||||
"""Convert a POLL block into an equivalent BUTTONS block.
|
||||
|
||||
Used when a channel has no native poll API. Each option becomes a
|
||||
``reply``-action button so a tap feeds the chosen option back into the next
|
||||
agent turn (reusing the existing reply routing and byte-safe callback
|
||||
encoding). The poll question is not part of the button row; renderers emit
|
||||
it as leading text (see :func:`adapt_presentation`).
|
||||
"""
|
||||
buttons: List[PresentationButton] = []
|
||||
for option in (block.poll_options or []):
|
||||
buttons.append(
|
||||
PresentationButton(
|
||||
label=str(option),
|
||||
action=PresentationAction.reply(str(option)),
|
||||
)
|
||||
)
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
def _clamp_table(
|
||||
block: PresentationBlock,
|
||||
limits: PresentationLimits,
|
||||
@@ -1068,26 +960,6 @@ def adapt_presentation(
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.POLL.value:
|
||||
if limits.supports_native_poll:
|
||||
adapted_blocks.append(block)
|
||||
continue
|
||||
# Degrade poll -> question text + reply buttons, then let the
|
||||
# buttons flow through the normal button adaptation below.
|
||||
question = block.text
|
||||
if (
|
||||
question is not None
|
||||
and limits.max_text_length
|
||||
and len(question) > limits.max_text_length
|
||||
):
|
||||
question = question[: limits.max_text_length]
|
||||
if question:
|
||||
adapted_blocks.append(
|
||||
PresentationBlock(type=BlockType.TEXT, text=question)
|
||||
)
|
||||
block = _poll_to_buttons(block, callback_store)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Degrade select -> buttons, then adapt the resulting buttons block
|
||||
block = _select_to_buttons(block, callback_store)
|
||||
@@ -1211,7 +1083,6 @@ DEGRADE_BUTTONS_TRUNCATED = "buttons_truncated"
|
||||
DEGRADE_OPTIONS_TRUNCATED = "options_truncated"
|
||||
DEGRADE_TABLE_AS_TEXT = "table_rendered_as_text"
|
||||
DEGRADE_CHART_AS_TEXT = "chart_rendered_as_text"
|
||||
DEGRADE_POLL_AS_BUTTONS = "poll_rendered_as_buttons"
|
||||
DEGRADE_CALLBACK_DATA_TOO_LONG = "callback_data_too_long"
|
||||
|
||||
|
||||
@@ -1287,16 +1158,6 @@ def _presentation_degradation(
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.POLL.value:
|
||||
if limits.supports_native_poll:
|
||||
continue
|
||||
# Follow the adapter: poll -> question text + reply buttons.
|
||||
n = len(block.poll_options or [])
|
||||
dropped.append(f"poll ({n} options) rendered as buttons")
|
||||
reasons.append(DEGRADE_POLL_AS_BUTTONS)
|
||||
block = _poll_to_buttons(block, callback_store)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Follow the adapter: select -> buttons (encoding option callbacks
|
||||
# exactly as adapt_presentation does), then treat as a buttons block.
|
||||
|
||||
@@ -31,7 +31,6 @@ from typing import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent import Agent
|
||||
from .base import SendResult
|
||||
|
||||
|
||||
class ChannelCapabilities(TypedDict, total=False):
|
||||
@@ -336,13 +335,6 @@ class GatewayRuntimeSeams:
|
||||
Attributes:
|
||||
identity_resolver: Cross-platform identity resolver; unifies the same
|
||||
human across platforms onto one session key.
|
||||
identity_canonicalizer: Identity-canonicalization seam (Issue #4661) —
|
||||
an :class:`IdentityCanonicalizerProtocol`
|
||||
(``canonicalize(platform, raw_user_id) -> str``) applied *before*
|
||||
the resolver, e.g. an adapter itself (see
|
||||
:meth:`BasePlatformAdapter.canonicalize`). Stabilises a volatile
|
||||
platform id (WhatsApp JID→LID, a number↔UUID alias flip) so the same
|
||||
human keeps one session instead of silently forking.
|
||||
delivery_router: Backing router for the built-in ``send_message`` tool,
|
||||
so an agent turn can proactively reach the user mid-task.
|
||||
admission_gate: Gateway-wide admission control (concurrency ceiling /
|
||||
@@ -355,7 +347,6 @@ class GatewayRuntimeSeams:
|
||||
"""
|
||||
|
||||
identity_resolver: Optional[Any] = None
|
||||
identity_canonicalizer: Optional[Any] = None
|
||||
delivery_router: Optional[Any] = None
|
||||
admission_gate: Optional[Any] = None
|
||||
turn_lock_map: Optional[Any] = None
|
||||
@@ -930,29 +921,17 @@ class BotProtocol(Protocol):
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Union[BotMessage, "SendResult"]:
|
||||
) -> BotMessage:
|
||||
"""Send a message to a channel.
|
||||
|
||||
|
||||
Args:
|
||||
channel_id: Target channel ID
|
||||
content: Message content
|
||||
reply_to: Optional message ID to reply to
|
||||
thread_id: Optional thread ID for threaded replies
|
||||
|
||||
|
||||
Returns:
|
||||
A **typed delivery outcome**. Adapters SHOULD return a
|
||||
:class:`~praisonaiagents.bots.SendResult` — the transport-neutral
|
||||
receipt that carries a closed, branchable ``status``
|
||||
(``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``), the
|
||||
platform ``message_id``/``message_ids`` (for edit-in-place, delete
|
||||
and threading), and a classified ``error``/``error_kind`` on
|
||||
failure. This makes a silent drop *unrepresentable*: a caller can
|
||||
branch on ``result.status`` instead of relying on an exception that
|
||||
may be swallowed.
|
||||
|
||||
A legacy :class:`BotMessage` remains an accepted return for
|
||||
backward compatibility — treat a returned ``BotMessage`` as a
|
||||
successful send (``status == "sent"``).
|
||||
The sent message
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -139,12 +139,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing state tracking
|
||||
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
|
||||
self._low_savings_streak: int = 0
|
||||
# Token count at the moment the streak reached its cap. The guard is
|
||||
# released once the conversation grows materially past this, so a
|
||||
# compactor that once could not shrink the context is never disabled
|
||||
# for the rest of the session.
|
||||
self._low_savings_tokens: int = 0
|
||||
|
||||
|
||||
# Iterative summary state
|
||||
self._previous_summary: Optional[str] = None
|
||||
self._previous_summary_global_idx: int = 0
|
||||
@@ -213,56 +208,14 @@ class ContextCompactor:
|
||||
- Token count is below threshold
|
||||
- We've had too many consecutive low-savings attempts
|
||||
"""
|
||||
total_tokens = self.count_total_tokens(messages)
|
||||
if total_tokens <= self.max_tokens:
|
||||
if self.count_total_tokens(messages) <= self.max_tokens:
|
||||
return False
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts,
|
||||
# unless the conversation has grown enough to be worth re-testing.
|
||||
if self._should_skip_low_savings(total_tokens):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
#: Growth factor that releases the anti-thrashing guard. A conversation
|
||||
#: 25% larger than the one we gave up on is materially different input,
|
||||
#: so the earlier "not worth compacting" verdict is re-tested.
|
||||
_LOW_SAVINGS_RETRY_GROWTH = 1.25
|
||||
|
||||
def _should_skip_low_savings(self, original_tokens: int) -> bool:
|
||||
"""Whether the anti-thrashing guard should skip this pass.
|
||||
|
||||
The guard stops a compactor that cannot shrink a conversation from
|
||||
burning a summarisation call every turn. It must not be permanent:
|
||||
``_low_savings_streak`` is only cleared *after* a compaction actually
|
||||
runs, so a compactor that trips the cap would otherwise skip forever
|
||||
and let the context grow without bound -- the precise opposite of what
|
||||
the guard is for.
|
||||
|
||||
Growth past the size we gave up on is new evidence, so the verdict is
|
||||
retried and the streak reset.
|
||||
"""
|
||||
if self._low_savings_streak < self.config.max_consecutive_low_savings:
|
||||
return False
|
||||
if self._low_savings_tokens and original_tokens >= (
|
||||
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
|
||||
):
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
|
||||
"""Update anti-thrashing state after a completed compaction pass."""
|
||||
self._last_savings_pct = savings_pct
|
||||
if savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return
|
||||
self._low_savings_streak += 1
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
# Remember the size we gave up at, so growth can release the guard.
|
||||
self._low_savings_tokens = original_tokens
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def compact(
|
||||
self,
|
||||
@@ -301,7 +254,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -370,7 +323,11 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
@@ -408,7 +365,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -459,7 +416,11 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -204,61 +204,52 @@ def evaluate_condition(
|
||||
else:
|
||||
substituted = substituted.replace(placeholder, str(value))
|
||||
|
||||
# A comparison operator in the *template* (not the substituted values)
|
||||
# is what makes this a comparison condition. When the template has no
|
||||
# operator, it is a plain flag/contains/truthy check and the comparison
|
||||
# parsers below must be skipped, otherwise a placeholder whose value
|
||||
# happens to look like a comparison (e.g. "{{flag}}" -> "5 < 3") would be
|
||||
# mis-parsed as a comparison instead of honoring the truthy-string path.
|
||||
template_has_comparison = re.search(r'>=|<=|==|!=|>|<', condition) is not None
|
||||
|
||||
# Now evaluate the substituted condition
|
||||
try:
|
||||
# Handle different condition formats
|
||||
|
||||
if template_has_comparison:
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# Contains check: "error in some message", "status contains success"
|
||||
if ' in ' in substituted:
|
||||
@@ -281,16 +272,7 @@ def evaluate_condition(
|
||||
return True
|
||||
if substituted.strip().lower() == 'false':
|
||||
return False
|
||||
|
||||
# A comparison operator in the template survived every format-specific
|
||||
# check above, so the comparison it was meant to drive never matched
|
||||
# (e.g. a missing or non-numeric operand). Fail safe per this
|
||||
# function's own contract instead of treating the leftover operator
|
||||
# text as a truthy string. Plain flag values (no template operator)
|
||||
# are untouched and fall through to the truthy check below.
|
||||
if template_has_comparison:
|
||||
return False
|
||||
|
||||
|
||||
# Non-empty string is truthy
|
||||
return bool(substituted.strip())
|
||||
|
||||
|
||||
@@ -196,11 +196,11 @@ _MODULE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
from .._lazy import create_lazy_getattr
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
name: (f"{__name__}.{submodule}", name)
|
||||
for name, submodule in _MODULE_MAP.items()
|
||||
}
|
||||
|
||||
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load config classes and utilities."""
|
||||
if name in _MODULE_MAP:
|
||||
module_name = _MODULE_MAP[name]
|
||||
import importlib
|
||||
module = importlib.import_module(f".{module_name}", __name__)
|
||||
return getattr(module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -1902,48 +1902,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 +1909,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 +1986,6 @@ __all__ = [
|
||||
"resolve_tool_search",
|
||||
"resolve_tools",
|
||||
"resolve_runtime",
|
||||
"canonical_runtime_name",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -245,83 +245,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 +295,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 +315,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.
|
||||
|
||||
@@ -690,19 +596,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 +649,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 = {}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from datetime import datetime
|
||||
|
||||
from ..llm.protocols import LLMProviderProtocol
|
||||
from .tokens import estimate_messages_tokens, get_estimator
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -375,7 +374,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
|
||||
# Try common OpenAI-compatible interface - check if async
|
||||
if hasattr(self.llm.chat.completions, 'acreate'):
|
||||
response = await self.llm.chat.completions.acreate(
|
||||
model=default_auxiliary_model(model),
|
||||
model=model or "gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
@@ -383,7 +382,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
|
||||
else:
|
||||
# Fallback to sync create (blocking in async context)
|
||||
response = self.llm.chat.completions.create(
|
||||
model=default_auxiliary_model(model),
|
||||
model=model or "gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
|
||||
@@ -10,7 +10,6 @@ from abc import ABC, abstractmethod
|
||||
from .models import ContextLedger, OptimizerStrategy, OptimizationResult
|
||||
from .tokens import estimate_messages_tokens, get_estimator
|
||||
from .compressor import ContextCompressor
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -639,7 +638,7 @@ class LLMContextCompressorOptimizer(BaseOptimizer):
|
||||
use_accurate_tokenizer: Use model-specific tokenizer if available
|
||||
"""
|
||||
self.llm_client = llm_client
|
||||
self.auxiliary_model = default_auxiliary_model(auxiliary_model)
|
||||
self.auxiliary_model = auxiliary_model or "gpt-4o-mini"
|
||||
self.protect_last_n_tokens = protect_last_n_tokens
|
||||
self.summary_target_tokens = summary_target_tokens
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@ MODEL_DIMENSIONS: Dict[str, int] = {
|
||||
# Jina models
|
||||
"jina-embeddings-v3": 1024,
|
||||
"jina-embeddings-v2-base-en": 768,
|
||||
# Locally-served embedders (Ollama tags). Dimensions measured by embedding a
|
||||
# probe string through a live Ollama 0.33.2 and counting the returned vector,
|
||||
# cross-checked against `<family>.embedding_length` from /api/show. Without
|
||||
# these every local embedder inherited DEFAULT_DIMENSION (1536, OpenAI's),
|
||||
# so a vector index was built 2x-4x the size the model actually produces.
|
||||
"nomic-embed-text": 768, # measured
|
||||
"mxbai-embed-large": 1024, # measured
|
||||
"all-minilm": 384, # measured; Ollama's short tag for all-MiniLM
|
||||
# HuggingFace common models
|
||||
"all-MiniLM-L6-v2": 384,
|
||||
"all-mpnet-base-v2": 768,
|
||||
@@ -78,30 +70,20 @@ def get_dimensions(model_name: str) -> int:
|
||||
1536
|
||||
>>> get_dimensions("openai/text-embedding-3-large")
|
||||
3072
|
||||
>>> get_dimensions("ollama/nomic-embed-text:v1.5")
|
||||
768
|
||||
>>> get_dimensions("unknown-model")
|
||||
1536
|
||||
"""
|
||||
model_lower = model_name.lower()
|
||||
|
||||
|
||||
# Check for exact match first (lowercased lookup handles mixed-case keys)
|
||||
if model_lower in _LOWER_MODEL_DIMENSIONS:
|
||||
return _LOWER_MODEL_DIMENSIONS[model_lower]
|
||||
|
||||
# Local models are written the way their runtime names them:
|
||||
# "ollama/nomic-embed-text:v1.5". Strip the litellm-style provider prefix
|
||||
# and the version tag, then retry the exact match, so a tagged local model
|
||||
# resolves instead of falling through to the OpenAI default.
|
||||
normalised = model_lower.rsplit("/", 1)[-1].split(":", 1)[0]
|
||||
if normalised != model_lower and normalised in _LOWER_MODEL_DIMENSIONS:
|
||||
return _LOWER_MODEL_DIMENSIONS[normalised]
|
||||
|
||||
# Check if model name contains known model identifiers.
|
||||
# Entries are pre-sorted by key length (longest first) so more specific
|
||||
# keys like "voyage-3-lite" match before shorter prefixes like "voyage-3".
|
||||
for model_key, dimensions in _SORTED_MODEL_DIMENSIONS:
|
||||
if model_key in model_lower or model_key in normalised:
|
||||
if model_key in model_lower:
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
|
||||
@@ -68,10 +68,6 @@ __all__ = [
|
||||
"EvalReport",
|
||||
"EvalPackage",
|
||||
"EvalRunnerProtocol",
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport",
|
||||
"from_evalport",
|
||||
"report_to_evalport",
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge",
|
||||
"JudgeConfig",
|
||||
@@ -164,10 +160,6 @@ _LAZY_IMPORTS = {
|
||||
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
|
||||
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
|
||||
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
|
||||
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
|
||||
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge": ("praisonaiagents.eval.judge", "Judge"),
|
||||
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
EvalPort adapter - framework-neutral interop for the eval package.
|
||||
|
||||
Converts PraisonAI's native eval data models to/from the EvalPort open spec
|
||||
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
|
||||
another tool's harness, and results can be diffed/aggregated across tools.
|
||||
|
||||
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
|
||||
|
||||
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
|
||||
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
|
||||
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
|
||||
|
||||
Design: this module is intentionally **dependency-free**. It emits and consumes
|
||||
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
|
||||
as a dependency. Callers who want schema validation can pass the returned dict to
|
||||
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
|
||||
|
||||
Example:
|
||||
>>> from praisonaiagents.eval import EvalPackage, EvalCase
|
||||
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
|
||||
>>> pkg = EvalPackage(name="support", cases=[
|
||||
... EvalCase(name="refund", input="refund policy?", expected="30 days")
|
||||
... ], thresholds={"accuracy": 0.9})
|
||||
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
|
||||
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
|
||||
>>> pkg2.name == pkg.name
|
||||
True
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .package import EvalCase, EvalPackage, EvalReport
|
||||
|
||||
EVALPORT_SPEC_VERSION = "1.0"
|
||||
|
||||
|
||||
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
|
||||
"""Map an ``EvalCase`` to an EvalPort test-case dict.
|
||||
|
||||
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
|
||||
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
|
||||
keeping round trips lossless for both.
|
||||
"""
|
||||
item: Dict[str, Any] = {
|
||||
"id": case.name,
|
||||
"input": case.input,
|
||||
}
|
||||
if case.expected is not None:
|
||||
item["expected_output"] = case.expected
|
||||
if case.criteria:
|
||||
item["graders"] = list(case.criteria)
|
||||
if case.timeout_seconds is not None:
|
||||
item["timeout_seconds"] = case.timeout_seconds
|
||||
metadata = dict(case.metadata or {})
|
||||
if metadata:
|
||||
item["metadata"] = metadata
|
||||
return item
|
||||
|
||||
|
||||
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
|
||||
"""Map an EvalPort test-case dict to an ``EvalCase``.
|
||||
|
||||
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
|
||||
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
|
||||
without mutating the caller's metadata.
|
||||
"""
|
||||
metadata = dict(item.get("metadata") or {})
|
||||
if "timeout_seconds" in item:
|
||||
timeout = item["timeout_seconds"]
|
||||
else:
|
||||
timeout = metadata.get("timeout_seconds", 30.0)
|
||||
return EvalCase(
|
||||
name=item.get("id") or item.get("name") or "case",
|
||||
input=item.get("input", ""),
|
||||
expected=item.get("expected_output", item.get("expected")),
|
||||
criteria=list(item.get("graders") or item.get("criteria") or []),
|
||||
metadata=metadata,
|
||||
timeout_seconds=timeout,
|
||||
)
|
||||
|
||||
|
||||
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
|
||||
|
||||
Args:
|
||||
package: The native PraisonAI eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
|
||||
"""
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "suite",
|
||||
"name": package.name,
|
||||
"description": package.description,
|
||||
"version": package.version,
|
||||
"cases": [_case_to_evalport(c) for c in package.cases],
|
||||
"thresholds": dict(package.thresholds or {}),
|
||||
"seed": package.seed,
|
||||
}
|
||||
|
||||
|
||||
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
|
||||
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
|
||||
|
||||
Args:
|
||||
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
|
||||
|
||||
Returns:
|
||||
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
|
||||
``HarnessEvaluator`` as native ``EvalCase`` objects.
|
||||
"""
|
||||
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
|
||||
return EvalPackage(
|
||||
name=suite.get("name", "suite"),
|
||||
description=suite.get("description", ""),
|
||||
version=suite.get("version", "1.0.0"),
|
||||
cases=cases,
|
||||
thresholds=dict(suite.get("thresholds") or {}),
|
||||
seed=suite.get("seed"),
|
||||
)
|
||||
|
||||
|
||||
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
|
||||
|
||||
Args:
|
||||
report: The aggregated report from running an eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for
|
||||
``openeval.validate.validate_result_set()``.
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
for r in report.results:
|
||||
item: Dict[str, Any] = {
|
||||
"case_id": r.case_name,
|
||||
"passed": r.passed,
|
||||
"score": r.score,
|
||||
"latency_ms": r.latency_ms,
|
||||
}
|
||||
if r.actual_output is not None:
|
||||
item["actual_output"] = r.actual_output
|
||||
if r.error is not None:
|
||||
item["error"] = r.error
|
||||
if r.criteria_scores:
|
||||
item["grader_scores"] = dict(r.criteria_scores)
|
||||
if r.record is not None:
|
||||
item["record"] = r.record
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "result_set",
|
||||
"suite_name": report.package_name,
|
||||
"summary": {
|
||||
"total": report.total_cases,
|
||||
"passed": report.passed_cases,
|
||||
"failed": report.failed_cases,
|
||||
"pass_rate": report.pass_rate,
|
||||
"average_score": report.average_score,
|
||||
"thresholds_met": dict(report.thresholds_met or {}),
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
@@ -83,10 +83,6 @@ from .protocols import (
|
||||
GatewayIdlePolicyProtocol,
|
||||
GatewayIdlePolicy, # backward-compat alias
|
||||
ScaleToZeroPolicy,
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
ThawDecision,
|
||||
ThawPolicyProtocol,
|
||||
WallClockGapThawPolicy,
|
||||
# Gateway graceful-drain on shutdown (Issue #2375)
|
||||
DrainDecision,
|
||||
GatewayDrainPolicyProtocol,
|
||||
@@ -109,10 +105,6 @@ from .protocols import (
|
||||
RateLimitPolicyProtocol,
|
||||
RateLimitPolicy, # backward-compat alias
|
||||
SlidingWindowRateLimitPolicy,
|
||||
# Gateway cost-aware spend-budget admission (Issue #4894)
|
||||
SpendBudgetPolicyProtocol,
|
||||
SpendBudgetPolicy, # backward-compat alias
|
||||
WindowedSpendBudgetPolicy,
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
PERMANENT_ERROR_CLASSES,
|
||||
DeadLetterDecision,
|
||||
@@ -135,12 +127,6 @@ from .protocols import (
|
||||
FatalConfigError,
|
||||
classify_exit_reason,
|
||||
RestartLoopGuard,
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
LifecycleRecord,
|
||||
classify_unclean_exit,
|
||||
RestartStoreProtocol,
|
||||
PersistentRestartLoopGuard,
|
||||
classify_resource_pressure,
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
FleetSupervisionPolicy,
|
||||
# Protocol version negotiation
|
||||
@@ -378,10 +364,6 @@ __all__ = [
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
"ThawDecision",
|
||||
"ThawPolicyProtocol",
|
||||
"WallClockGapThawPolicy",
|
||||
"DrainDecision",
|
||||
"GatewayDrainPolicyProtocol",
|
||||
"DrainTimeoutPolicy",
|
||||
@@ -403,9 +385,6 @@ __all__ = [
|
||||
"RateLimitPolicyProtocol",
|
||||
"RateLimitPolicy",
|
||||
"SlidingWindowRateLimitPolicy",
|
||||
"SpendBudgetPolicyProtocol",
|
||||
"SpendBudgetPolicy",
|
||||
"WindowedSpendBudgetPolicy",
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
"PERMANENT_ERROR_CLASSES",
|
||||
"DeadLetterDecision",
|
||||
@@ -428,12 +407,6 @@ __all__ = [
|
||||
"FatalConfigError",
|
||||
"classify_exit_reason",
|
||||
"RestartLoopGuard",
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
"LifecycleRecord",
|
||||
"classify_unclean_exit",
|
||||
"RestartStoreProtocol",
|
||||
"PersistentRestartLoopGuard",
|
||||
"classify_resource_pressure",
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
"FleetSupervisionPolicy",
|
||||
# Protocol version negotiation
|
||||
|
||||
@@ -6,7 +6,6 @@ Provides configuration dataclasses for gateway and session settings.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
@@ -19,9 +18,6 @@ from typing import (
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only, no runtime import cost
|
||||
from .protocols import TurnExecutorProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical config version + doctor-driven migration (Issue #3841)
|
||||
@@ -858,14 +854,6 @@ class GatewayConfig:
|
||||
control: "EmergencyStopConfig" = field(
|
||||
default_factory=lambda: EmergencyStopConfig()
|
||||
)
|
||||
# Issue #4766: per-session turn-execution seam. Selects *where* a session's
|
||||
# agent turn runs (see ``TurnExecutorProtocol``). ``None`` (the default)
|
||||
# resolves to ``InProcessTurnExecutor`` at runtime — today's on-loop
|
||||
# behaviour, byte-for-byte, with no dependency introduced. Supplying an
|
||||
# isolated executor (subprocess / container / remote, in the wrapper)
|
||||
# contains a wedged/runaway turn to its own worker instead of forcing the
|
||||
# whole-process ``os._exit`` remedy.
|
||||
executor: "Optional[TurnExecutorProtocol]" = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -989,13 +977,6 @@ class GatewayConfig:
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
"control": self.control.to_dict(),
|
||||
# Report the active executor type so ``gateway doctor`` can surface
|
||||
# it; ``None`` means the in-process default (today's behaviour).
|
||||
"executor": (
|
||||
type(self.executor).__name__
|
||||
if self.executor is not None
|
||||
else "inprocess"
|
||||
),
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -1938,16 +1938,7 @@ class RouteBinding:
|
||||
chat_type: Chat type ("dm" | "group" | "channel").
|
||||
peer: Sender/user id (most specific).
|
||||
role: Role / guild-role membership of the sender.
|
||||
channel_id: Specific chat/channel id. Also matches a message whose
|
||||
parent channel is this id (a thread/forum-post under it), so a
|
||||
channel-level route naturally covers the threads beneath it
|
||||
without listing each thread by hand (Issue #4839).
|
||||
thread_id: Specific thread / forum-post id (most specific unit — one
|
||||
triage-spun support thread can route to a specialist agent,
|
||||
Issue #4839).
|
||||
guild_id: Server / workspace id. One rule covers every current and
|
||||
future channel in the guild, so an operator can multiplex by the
|
||||
tenant boundary they actually think in (Issue #4839).
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
|
||||
@@ -1976,21 +1967,13 @@ class RouteBinding:
|
||||
allow_tools: Optional[List[str]] = None
|
||||
deny_tools: Optional[List[str]] = None
|
||||
profile: Optional[str] = None
|
||||
# New optional conditions are appended here (never inserted mid-list) so
|
||||
# existing positional constructors — RouteBinding("a", "dm", ..., priority)
|
||||
# — keep their meaning and stay backward-compatible (Issue #4839 review).
|
||||
thread_id: Optional[str] = None
|
||||
guild_id: Optional[str] = None
|
||||
|
||||
# Specificity weights — exact thread beats peer beats role/channel beats
|
||||
# guild/account beats chat-type. Higher means more specific, so a single
|
||||
# thread rule wins over a whole-guild rule (Issue #4839).
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
_SPECIFICITY = {
|
||||
"thread_id": 32,
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"guild_id": 4,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
@@ -2034,20 +2017,7 @@ class RouteBinding:
|
||||
"""Return True if every constrained condition equals the facts."""
|
||||
if self.peer is not None and str(self.peer) != str(facts.peer):
|
||||
return False
|
||||
if self.channel_id is not None:
|
||||
# Parent-chain match (Issue #4839): a channel_id condition matches
|
||||
# the channel itself OR a thread/forum-post whose parent is that
|
||||
# channel, so channel-level routes cover the threads beneath them
|
||||
# without enumerating each thread.
|
||||
expected_channel = str(self.channel_id)
|
||||
candidates = {str(facts.channel_id)}
|
||||
if facts.parent_channel_id is not None:
|
||||
candidates.add(str(facts.parent_channel_id))
|
||||
if expected_channel not in candidates:
|
||||
return False
|
||||
if self.thread_id is not None and str(self.thread_id) != str(facts.thread_id):
|
||||
return False
|
||||
if self.guild_id is not None and str(self.guild_id) != str(facts.guild_id):
|
||||
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
@@ -2096,8 +2066,6 @@ class RouteBinding:
|
||||
role=data.get("role"),
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
thread_id=_as_opt_str(data.get("thread_id")),
|
||||
guild_id=_as_opt_str(data.get("guild_id")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
trust=_as_opt_str(data.get("trust")),
|
||||
allow_tools=_as_opt_str_list(data.get("allow_tools")),
|
||||
@@ -2116,13 +2084,6 @@ class RouteFacts:
|
||||
roles: Roles/guild-role memberships of the sender.
|
||||
channel_id: The chat/channel id the message arrived in.
|
||||
account: The receiving bot account (multi-account channels).
|
||||
thread_id: The thread / forum-post id the message arrived in, when the
|
||||
platform threads conversations (Issue #4839).
|
||||
guild_id: The server / workspace id the message belongs to (Discord
|
||||
guild, Slack workspace), used for whole-server routing (Issue #4839).
|
||||
parent_channel_id: The parent channel of a thread/forum-post, so a
|
||||
channel-level binding can cover the threads beneath it via
|
||||
parent-chain matching (Issue #4839).
|
||||
"""
|
||||
|
||||
chat_type: str = "default"
|
||||
@@ -2130,9 +2091,6 @@ class RouteFacts:
|
||||
roles: List[str] = field(default_factory=list)
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
thread_id: Optional[str] = None
|
||||
guild_id: Optional[str] = None
|
||||
parent_channel_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -2190,9 +2148,8 @@ def resolve_route(
|
||||
"""Resolve the handling agent from priority-ordered bindings.
|
||||
|
||||
Bindings are evaluated most-specific-first: the matching binding with the
|
||||
highest ``priority`` wins; ties are broken by specificity (exact thread →
|
||||
peer → role/channel → guild/account → chat-type), then by declaration
|
||||
order.
|
||||
highest ``priority`` wins; ties are broken by specificity (exact peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
@@ -3424,150 +3381,6 @@ class ScaleToZeroPolicy:
|
||||
GatewayIdlePolicy = GatewayIdlePolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThawDecision:
|
||||
"""Result of a freeze-thaw (host-suspend gap) evaluation.
|
||||
|
||||
Attributes:
|
||||
suspended: Whether an involuntary host-suspend gap was detected
|
||||
since the previous observation.
|
||||
gap_seconds: Estimated duration the host was frozen (0.0 when not
|
||||
suspended).
|
||||
restart_transports: Whether the wrapper should re-probe / restart
|
||||
outbound channel sockets (idle-gated) because they may be
|
||||
silently half-dead after the freeze.
|
||||
reconcile_schedule: Whether the wrapper should reconcile the
|
||||
scheduler (coalesce missed cron fires) against the gap instead
|
||||
of letting wall-clock catch-up storm on resume.
|
||||
"""
|
||||
|
||||
suspended: bool
|
||||
gap_seconds: float = 0.0
|
||||
restart_transports: bool = False
|
||||
reconcile_schedule: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ThawPolicyProtocol(Protocol):
|
||||
"""Protocol for freeze-thaw (involuntary host-suspend) detection.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's
|
||||
``BotOS`` run-loop. The wrapper ticks the policy with a matched pair
|
||||
of ``time.monotonic()`` and ``time.time()`` readings; the policy
|
||||
compares the elapsed monotonic delta against the elapsed wall-clock
|
||||
delta and returns a :class:`ThawDecision`. On Linux ``CLOCK_MONOTONIC``
|
||||
does not advance while the host is suspended, so a wall-clock delta
|
||||
that runs far ahead of the monotonic delta is a positive signature of
|
||||
a freeze the process could not otherwise observe. Concrete recovery
|
||||
(restart channel sockets when idle, refresh presence, reconcile the
|
||||
scheduler) lives in the wrapper; this contract keeps the *detection*
|
||||
testable in isolation.
|
||||
"""
|
||||
|
||||
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
|
||||
"""Return a :class:`ThawDecision` for the supplied clock readings."""
|
||||
...
|
||||
|
||||
|
||||
class WallClockGapThawPolicy:
|
||||
"""Config-driven default freeze-thaw detector.
|
||||
|
||||
The default referenced by ``gateway.thaw:`` blocks in ``gateway.yaml``
|
||||
and the ``BotOS(..., thaw_policy=...)`` Python surface. It is
|
||||
intentionally minimal and dependency-free so the decision lives in
|
||||
core and is provable in isolation; the wrapper owns the side effects
|
||||
(restart channel sockets, refresh presence, reconcile the scheduler).
|
||||
|
||||
A suspend gap is detected when, between two consecutive
|
||||
:meth:`observe` calls, the wall-clock delta ran ahead of the monotonic
|
||||
delta by more than ``gap_threshold_s`` *and* the monotonic clock itself
|
||||
stayed near-frozen (advanced by no more than one tick plus a small
|
||||
tolerance). Both conditions are the true signature of a frozen host:
|
||||
wall-clock jumps forward while ``CLOCK_MONOTONIC`` stalls. The reported
|
||||
``gap_seconds`` is the wall-vs-monotonic divergence — the time the host
|
||||
was actually frozen.
|
||||
|
||||
Requiring the monotonic delta to stay near-frozen distinguishes a real
|
||||
host suspend from a forward *wall-clock correction* (NTP step / manual
|
||||
clock set) that occurs while the process is running normally: in the
|
||||
correction case monotonic keeps advancing on schedule, so no recovery
|
||||
is requested and healthy transports/schedule are left untouched.
|
||||
|
||||
The first observation only seeds the baseline and never reports a gap,
|
||||
so the default preserves current behaviour when no freeze occurs.
|
||||
|
||||
Example::
|
||||
|
||||
WallClockGapThawPolicy(tick_interval_s=15.0, gap_threshold_s=60.0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tick_interval_s: float = 15.0,
|
||||
gap_threshold_s: float = 60.0,
|
||||
enabled: bool = True,
|
||||
):
|
||||
if tick_interval_s <= 0:
|
||||
raise ValueError(
|
||||
f"tick_interval_s must be > 0, got {tick_interval_s!r}"
|
||||
)
|
||||
if gap_threshold_s <= 0:
|
||||
raise ValueError(
|
||||
f"gap_threshold_s must be > 0, got {gap_threshold_s!r}"
|
||||
)
|
||||
self.tick_interval_s = float(tick_interval_s)
|
||||
self.gap_threshold_s = float(gap_threshold_s)
|
||||
self.enabled = bool(enabled)
|
||||
self._last_monotonic: Optional[float] = None
|
||||
self._last_wall: Optional[float] = None
|
||||
|
||||
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
|
||||
if not self.enabled:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
prev_monotonic = self._last_monotonic
|
||||
prev_wall = self._last_wall
|
||||
self._last_monotonic = monotonic_now
|
||||
self._last_wall = wall_now
|
||||
|
||||
# First tick only seeds the baseline; nothing to compare against.
|
||||
if prev_monotonic is None or prev_wall is None:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
monotonic_delta = monotonic_now - prev_monotonic
|
||||
wall_delta = wall_now - prev_wall
|
||||
# Divergence: how far wall-clock ran ahead of the process's own
|
||||
# elapsed (monotonic) time — i.e. the frozen window.
|
||||
divergence = wall_delta - monotonic_delta
|
||||
|
||||
# A real host suspend freezes CLOCK_MONOTONIC: it stalls, so across
|
||||
# the gap it advances by less than one scheduled tick. A forward
|
||||
# wall-clock *correction* (NTP step / manual set) instead leaves
|
||||
# monotonic advancing in step with the loop — a full tick or more —
|
||||
# proving the process kept running. Requiring monotonic to have
|
||||
# stalled below one tick rejects that false positive so healthy
|
||||
# transports and the scheduler are left untouched.
|
||||
monotonic_frozen = monotonic_delta < self.tick_interval_s
|
||||
gap_detected = (
|
||||
divergence > self.gap_threshold_s
|
||||
and monotonic_frozen
|
||||
)
|
||||
if not gap_detected:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
return ThawDecision(
|
||||
suspended=True,
|
||||
gap_seconds=divergence,
|
||||
restart_transports=True,
|
||||
reconcile_schedule=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway graceful-drain on shutdown (Issue #2375)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4315,193 +4128,6 @@ class SlidingWindowRateLimitPolicy:
|
||||
RateLimitPolicy = RateLimitPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway cost-aware spend-budget admission (Issue #4894)
|
||||
#
|
||||
# Rate limiting gates on *request count*; a public multi-user gateway also
|
||||
# needs to gate on *cumulative cost per identity*. This mirrors
|
||||
# :class:`RateLimitPolicyProtocol` exactly — a pure, import-free decision over
|
||||
# typed facts (an identity, a scope, the amount already ``spent_usd`` in the
|
||||
# window and a timestamp) returning the same closed :class:`RateLimitDecision`.
|
||||
# The wrapper supplies ``spent_usd`` from a durable
|
||||
# :class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`
|
||||
# (e.g. :class:`~praisonaiagents.telemetry.durable_sink.SqliteTokenUsageSink`)
|
||||
# and consults this policy at the same admit decision as concurrency /
|
||||
# rate-limit, *before* the LLM call. Concrete state and storage live outside
|
||||
# the decision, keeping it injectable and testable in isolation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SpendBudgetPolicyProtocol(Protocol):
|
||||
"""Protocol for gateway cumulative-spend admission decisions.
|
||||
|
||||
Pure, import-free decision contract symmetric with
|
||||
:class:`RateLimitPolicyProtocol`. The wrapper supplies typed facts — the
|
||||
caller ``identity``, the ``scope`` (channel / tenant token), the amount
|
||||
already ``spent_usd`` for that identity+scope in the current window, the
|
||||
current ``now`` timestamp, and (optionally) the ``pending_usd`` estimated
|
||||
cost of the turn being admitted and the ``oldest_spend_ts`` of the spend in
|
||||
the window — and the policy decides whether the turn is allowed or
|
||||
rejected/throttled with a ``retry_after_seconds`` hint. Storage of the
|
||||
spend ledger lives in a
|
||||
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`; this
|
||||
contract keeps only the *decision* injectable and provable in isolation.
|
||||
|
||||
A config-driven default (:class:`WindowedSpendBudgetPolicy`) is provided
|
||||
for the common "cap $X per identity per rolling window" case.
|
||||
"""
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
spent_usd: float,
|
||||
now: float,
|
||||
pending_usd: float = 0.0,
|
||||
oldest_spend_ts: Optional[float] = None,
|
||||
) -> RateLimitDecision:
|
||||
"""Return a :class:`RateLimitDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class WindowedSpendBudgetPolicy:
|
||||
"""Config-driven cumulative per-identity spend-budget policy.
|
||||
|
||||
Intended as the default the wrapper will wire onto a forthcoming
|
||||
``gateway.budget`` config block and a ``spend_budget_policy=`` gateway
|
||||
constructor argument (neither of which exists in the repo yet — this PR
|
||||
ships only the core decision primitive; the CLI/YAML and constructor
|
||||
surfaces are a follow-up wrapper change). It is intentionally minimal and
|
||||
dependency-free so the decision lives in core and is provable in isolation,
|
||||
exactly like :class:`SlidingWindowRateLimitPolicy`.
|
||||
|
||||
The decision, given the amount ``spent_usd`` already accumulated for the
|
||||
identity+scope in the current window and the ``pending_usd`` estimated cost
|
||||
of the turn being admitted:
|
||||
|
||||
* ``allowed`` while ``spent_usd + pending_usd`` is below ``limit_usd``.
|
||||
* Otherwise the turn is denied with a ``retry_after_seconds`` hint equal to
|
||||
the time until enough spend rolls out of the window. When
|
||||
``oldest_spend_ts`` is supplied the hint is computed precisely
|
||||
(``oldest_spend_ts + window_seconds - now``); without it the policy falls
|
||||
back to the full ``window_seconds`` as a safe upper bound.
|
||||
|
||||
Passing ``pending_usd`` lets a caller reserve budget for the turn's
|
||||
estimated cost *before* the LLM call so a single expensive turn cannot
|
||||
overshoot the cap; leaving it at the default ``0.0`` preserves the simpler
|
||||
"gate on spend so far" behaviour.
|
||||
|
||||
A ``limit_usd`` of ``0`` (or negative) disables the budget entirely (every
|
||||
turn is allowed) — the legacy default when no budget is configured.
|
||||
|
||||
Unlike :class:`SlidingWindowRateLimitPolicy` this policy is *stateless*: the
|
||||
cumulative spend is owned by a durable
|
||||
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`, so the
|
||||
same budget survives restarts and is shared across processes.
|
||||
|
||||
Example::
|
||||
|
||||
WindowedSpendBudgetPolicy(limit_usd=2.00, window_seconds=86_400)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
limit_usd: float = 0.0,
|
||||
window_seconds: float = 86_400.0,
|
||||
):
|
||||
try:
|
||||
limit = float(limit_usd)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"limit_usd must be a number, got {limit_usd!r}"
|
||||
) from err
|
||||
try:
|
||||
window = float(window_seconds)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"window_seconds must be a number, got {window_seconds!r}"
|
||||
) from err
|
||||
if window <= 0:
|
||||
raise ValueError(
|
||||
f"window_seconds must be > 0, got {window_seconds!r}"
|
||||
)
|
||||
self.limit_usd = limit
|
||||
self.window_seconds = window
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether budgeting is active (a positive limit is set)."""
|
||||
return self.limit_usd > 0
|
||||
|
||||
def window_start(self, now: float) -> float:
|
||||
"""Start timestamp of the current window for a ``now`` timestamp.
|
||||
|
||||
Helper for the wrapper so it can query the durable sink for
|
||||
``spent_usd`` over the same window the policy enforces.
|
||||
"""
|
||||
return float(now) - self.window_seconds
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
spent_usd: float,
|
||||
now: float,
|
||||
pending_usd: float = 0.0,
|
||||
oldest_spend_ts: Optional[float] = None,
|
||||
) -> RateLimitDecision:
|
||||
# Disabled: preserve legacy always-allow behaviour.
|
||||
if self.limit_usd <= 0:
|
||||
return RateLimitDecision(allowed=True)
|
||||
try:
|
||||
spent = float(spent_usd)
|
||||
except (TypeError, ValueError):
|
||||
spent = 0.0
|
||||
try:
|
||||
pending = float(pending_usd)
|
||||
except (TypeError, ValueError):
|
||||
pending = 0.0
|
||||
if pending < 0:
|
||||
pending = 0.0
|
||||
# Reserve budget for the pending turn's estimated cost so a single
|
||||
# expensive turn cannot overshoot the cap before its cost is recorded.
|
||||
if spent + pending < self.limit_usd:
|
||||
return RateLimitDecision(allowed=True)
|
||||
return RateLimitDecision(
|
||||
allowed=False,
|
||||
retry_after_seconds=self._retry_after(now, oldest_spend_ts),
|
||||
)
|
||||
|
||||
def _retry_after(
|
||||
self, now: float, oldest_spend_ts: Optional[float]
|
||||
) -> float:
|
||||
"""Seconds until enough spend rolls out of the rolling window.
|
||||
|
||||
With ``oldest_spend_ts`` the earliest in-window charge expires at
|
||||
``oldest_spend_ts + window_seconds``; the hint is the time until then,
|
||||
clamped to ``(0, window_seconds]``. Without it, fall back to the full
|
||||
window as a safe upper bound.
|
||||
"""
|
||||
if oldest_spend_ts is None:
|
||||
return self.window_seconds
|
||||
try:
|
||||
remaining = (float(oldest_spend_ts) + self.window_seconds) - float(now)
|
||||
except (TypeError, ValueError):
|
||||
return self.window_seconds
|
||||
if remaining <= 0:
|
||||
# Spend has already aged out; retry effectively immediately.
|
||||
return 0.0
|
||||
return min(remaining, self.window_seconds)
|
||||
|
||||
|
||||
# Backward-compatible alias following the repo's ``*Protocol`` convention.
|
||||
SpendBudgetPolicy = SpendBudgetPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
#
|
||||
@@ -5177,308 +4803,6 @@ class RestartLoopGuard:
|
||||
self._events = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
#
|
||||
# ``RestartLoopGuard`` above tracks the *rate* of restart-worthy boots in
|
||||
# memory, and ``classify_exit_reason`` maps a caught exception to an exit code.
|
||||
# Neither survives a ``SIGKILL``/OOM-kill — no in-process hook runs for those —
|
||||
# so a hosted gateway cannot tell on its next boot that its previous process
|
||||
# died uncleanly. These three pure primitives close that gap: a small durable
|
||||
# lifecycle *record* the runtime restamps on boot/shutdown, a pure predicate
|
||||
# that classifies "was the last exit clean?", and a restart-loop guard whose
|
||||
# events load from / flush to an injected durable store so the breaker survives
|
||||
# the very death it guards. All I/O — the ``/proc``/cgroup sampling and the
|
||||
# durable sentinel — lives in the ``praisonai-bot`` runtime; core stays
|
||||
# protocol + pure default policy, symmetric with the block above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleRecord:
|
||||
"""Durable, boot-scoped snapshot of the gateway process's lifecycle.
|
||||
|
||||
Written by the runtime at boot (``phase="running"``) and on a graceful stop
|
||||
(``phase="exited"``), with a memory sample folded in by the heartbeat. On
|
||||
the *next* boot the runtime reads the prior record and runs
|
||||
:func:`classify_unclean_exit`: a record still marked ``running`` means the
|
||||
previous process never reached its graceful-stop path — an unclean exit,
|
||||
and (given a high last memory sample) a suspected OOM-kill.
|
||||
|
||||
Pure and JSON-round-trippable so both the durable store and the health
|
||||
surface share one shape; the wrapper owns persistence.
|
||||
|
||||
Attributes:
|
||||
phase: ``"running"`` | ``"draining"`` | ``"exited"`` — the last phase
|
||||
the process recorded for itself.
|
||||
exit_kind: ``"clean"`` | ``"unclean"`` | ``"unknown"`` — set by
|
||||
:func:`classify_unclean_exit` on the *next* boot (never
|
||||
``"unknown"``-treated-as-healthy downstream).
|
||||
suspected_oom: ``True`` when an unclean exit coincided with a high last
|
||||
memory sample (memory pressure at death).
|
||||
last_mem_fraction: Last sampled memory-budget fraction in ``[0, 1]``, or
|
||||
``None`` when the platform could not report it.
|
||||
restart_events: Persisted monotonic-independent wall-clock timestamps of
|
||||
restart-worthy boots, for :class:`PersistentRestartLoopGuard`.
|
||||
"""
|
||||
|
||||
phase: Literal["running", "draining", "exited"] = "running"
|
||||
exit_kind: Literal["clean", "unclean", "unknown"] = "unknown"
|
||||
suspected_oom: bool = False
|
||||
last_mem_fraction: Optional[float] = None
|
||||
restart_events: List[float] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serializable dict for the durable sentinel."""
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"exit_kind": self.exit_kind,
|
||||
"suspected_oom": self.suspected_oom,
|
||||
"last_mem_fraction": self.last_mem_fraction,
|
||||
"restart_events": list(self.restart_events),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "LifecycleRecord":
|
||||
"""Rebuild from a persisted dict, tolerating partial/legacy shapes."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
|
||||
phase = data.get("phase")
|
||||
if phase not in ("running", "draining", "exited"):
|
||||
phase = "running"
|
||||
exit_kind = data.get("exit_kind")
|
||||
if exit_kind not in ("clean", "unclean", "unknown"):
|
||||
exit_kind = "unknown"
|
||||
|
||||
frac = data.get("last_mem_fraction")
|
||||
try:
|
||||
frac_val: Optional[float] = None if frac is None else float(frac)
|
||||
except (TypeError, ValueError):
|
||||
frac_val = None
|
||||
|
||||
raw_events = data.get("restart_events") or []
|
||||
events: List[float] = []
|
||||
if isinstance(raw_events, (list, tuple)):
|
||||
for item in raw_events:
|
||||
try:
|
||||
events.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return cls(
|
||||
phase=phase, # type: ignore[arg-type]
|
||||
exit_kind=exit_kind, # type: ignore[arg-type]
|
||||
suspected_oom=bool(data.get("suspected_oom", False)),
|
||||
last_mem_fraction=frac_val,
|
||||
restart_events=events,
|
||||
)
|
||||
|
||||
|
||||
def classify_unclean_exit(
|
||||
prev: Optional[LifecycleRecord],
|
||||
*,
|
||||
oom_fraction: float = 0.9,
|
||||
) -> LifecycleRecord:
|
||||
"""Classify the previous boot's exit and return a fresh boot record (pure).
|
||||
|
||||
Given the record persisted by the *previous* process, decide whether that
|
||||
process exited cleanly and stamp a new ``phase="running"`` record for *this*
|
||||
boot. Side-effect free so it is provable in isolation, mirroring
|
||||
:func:`classify_exit_reason`.
|
||||
|
||||
Rules:
|
||||
|
||||
* No prior record ⇒ first boot / fresh store: ``exit_kind="unknown"`` (the
|
||||
caller must never treat ``"unknown"`` as healthy, but there is nothing to
|
||||
resume).
|
||||
* Prior ``phase == "exited"`` ⇒ the previous process reached its graceful
|
||||
stop: ``exit_kind="clean"``.
|
||||
* Prior ``phase in ("running", "draining")`` ⇒ the previous process died
|
||||
before recording a clean exit (SIGKILL/OOM/segfault/orchestrator kill):
|
||||
``exit_kind="unclean"``, and ``suspected_oom`` when its last memory sample
|
||||
was at/above ``oom_fraction`` of the budget.
|
||||
|
||||
The returned record carries forward the prior ``restart_events`` so the
|
||||
persistent restart-loop guard sees the full cross-boot history.
|
||||
|
||||
Args:
|
||||
prev: The previous process's persisted :class:`LifecycleRecord`, or
|
||||
``None`` on a fresh store.
|
||||
oom_fraction: Memory-budget fraction at/above which an unclean exit is
|
||||
flagged as a suspected OOM-kill.
|
||||
|
||||
Returns:
|
||||
A fresh ``phase="running"`` :class:`LifecycleRecord` for this boot.
|
||||
"""
|
||||
carried = list(prev.restart_events) if prev is not None else []
|
||||
if prev is None:
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="unknown", restart_events=carried
|
||||
)
|
||||
if prev.phase == "exited":
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="clean", restart_events=carried
|
||||
)
|
||||
# Still "running"/"draining" in the prior record ⇒ never reached graceful stop.
|
||||
frac = prev.last_mem_fraction
|
||||
suspected_oom = frac is not None and frac >= oom_fraction
|
||||
return LifecycleRecord(
|
||||
phase="running",
|
||||
exit_kind="unclean",
|
||||
suspected_oom=suspected_oom,
|
||||
last_mem_fraction=frac,
|
||||
restart_events=carried,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RestartStoreProtocol(Protocol):
|
||||
"""Durable-store contract for cross-boot restart timestamps (Issue #4603).
|
||||
|
||||
The pure decision (:class:`PersistentRestartLoopGuard`) lives in core; the
|
||||
wrapper injects a concrete store that reads/writes the timestamps to a small
|
||||
sentinel file. Both methods are best-effort: an implementation that cannot
|
||||
reach its backing store returns ``[]`` from :meth:`load_events` and silently
|
||||
drops :meth:`save_events`, so the breaker degrades to in-memory behaviour
|
||||
rather than crashing the gateway it protects.
|
||||
"""
|
||||
|
||||
def load_events(self) -> List[float]:
|
||||
"""Return the persisted restart timestamps (``[]`` when unavailable)."""
|
||||
...
|
||||
|
||||
def save_events(self, events: List[float]) -> None:
|
||||
"""Persist the restart timestamps (best-effort, never raises)."""
|
||||
...
|
||||
|
||||
|
||||
class PersistentRestartLoopGuard(RestartLoopGuard):
|
||||
"""A :class:`RestartLoopGuard` whose events survive a process restart.
|
||||
|
||||
The in-memory guard is reconstructed fresh on every boot, so a persisted
|
||||
turn that hard-crashes the *whole* process (OOM, segfault) can drive an
|
||||
infinite ``supervisor → boot → auto-resume → crash`` loop the breaker never
|
||||
sees. This subclass loads its event history from an injected
|
||||
:class:`RestartStoreProtocol` at construction and flushes back on every
|
||||
mutation, so the breaker observes the true cross-boot restart rate and can
|
||||
trip on a crash cycle *slower* than a single in-process window.
|
||||
|
||||
The window defaults to one hour (vs. the in-process 60s) because a
|
||||
cross-boot crash loop is paced by process (re)start latency, not by an
|
||||
in-loop retry. Pure decision preserved: all persistence is delegated to the
|
||||
injected store, so the trip logic remains provable in isolation.
|
||||
|
||||
Example::
|
||||
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=3,
|
||||
window_seconds=3600)
|
||||
if guard.record(now=time.time()):
|
||||
# too many restart-interrupted boots — stop auto-resuming the turn
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: RestartStoreProtocol,
|
||||
*,
|
||||
max_restarts: int = 3,
|
||||
window_seconds: float = 3600.0,
|
||||
):
|
||||
super().__init__(max_restarts=max_restarts, window_seconds=window_seconds)
|
||||
self._store = store
|
||||
try:
|
||||
loaded = store.load_events()
|
||||
except Exception:
|
||||
loaded = []
|
||||
if loaded:
|
||||
self._events = [float(t) for t in loaded]
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._store.save_events(list(self._events))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def record(self, now: float) -> bool:
|
||||
"""Record a restart at wall-clock ``now`` and flush to the store.
|
||||
|
||||
Unlike the base class this expects a *wall-clock* timestamp
|
||||
(``time.time()``), because a monotonic clock resets across process
|
||||
boots and would make persisted events meaningless.
|
||||
"""
|
||||
tripped = super().record(now)
|
||||
self._flush()
|
||||
return tripped
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear the history in memory *and* in the durable store."""
|
||||
super().reset()
|
||||
self._flush()
|
||||
|
||||
|
||||
def classify_resource_pressure(
|
||||
*,
|
||||
mem_fraction: Optional[float] = None,
|
||||
disk_free_mb: Optional[float] = None,
|
||||
mem_elevated: float = 0.75,
|
||||
mem_critical: float = 0.9,
|
||||
disk_min_free_mb: float = 512.0,
|
||||
) -> Dict[str, str]:
|
||||
"""Classify coarse memory + disk pressure for the health surface (pure).
|
||||
|
||||
A tiny, redaction-safe classifier that folds the wrapper's ``/proc``/cgroup
|
||||
memory-budget fraction and free-disk sample into a closed ``ok`` /
|
||||
``elevated`` / ``critical`` / ``unknown`` vocabulary. Lives in core so the
|
||||
SDK and the bot classify identically; the wrapper owns the sampling.
|
||||
|
||||
``unknown`` is returned when a signal is missing and — per the issue — must
|
||||
never be treated as healthy by callers (it is deliberately *not* ``ok``).
|
||||
|
||||
Args:
|
||||
mem_fraction: Anonymous-RSS / memory-budget fraction in ``[0, ∞)``, or
|
||||
``None`` when the platform has no cgroup limit to report against.
|
||||
disk_free_mb: Free disk headroom in MiB for the gateway's state dir, or
|
||||
``None`` when it could not be sampled.
|
||||
mem_elevated: Fraction at/above which memory is ``"elevated"``.
|
||||
mem_critical: Fraction at/above which memory is ``"critical"``.
|
||||
disk_min_free_mb: Free-MiB floor below which disk is ``"critical"``;
|
||||
``"elevated"`` below twice that.
|
||||
|
||||
Returns:
|
||||
``{"memory": <level>, "disk": <level>}`` with values drawn from the
|
||||
closed vocabulary above.
|
||||
"""
|
||||
|
||||
def _mem(frac: Optional[float]) -> str:
|
||||
if frac is None:
|
||||
return "unknown"
|
||||
try:
|
||||
f = float(frac)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if f >= mem_critical:
|
||||
return "critical"
|
||||
if f >= mem_elevated:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
def _disk(free_mb: Optional[float]) -> str:
|
||||
if free_mb is None:
|
||||
return "unknown"
|
||||
try:
|
||||
mb = float(free_mb)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if mb < disk_min_free_mb:
|
||||
return "critical"
|
||||
if mb < disk_min_free_mb * 2:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
return {"memory": _mem(mem_fraction), "disk": _disk(disk_free_mb)}
|
||||
|
||||
|
||||
class FleetSupervisionPolicy:
|
||||
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
|
||||
|
||||
@@ -6951,16 +6275,8 @@ def resolve_required_scope(
|
||||
# status/read needs READ. Anything unregistered defaults to ADMIN (deny).
|
||||
def _register_core_gateway_methods() -> None:
|
||||
core: Dict[str, OperatorScope] = {
|
||||
# Connection lifecycle. These run before a client can hold any scope, so
|
||||
# they must not require one -- classifying them ADMIN by omission is what
|
||||
# kept resolve_required_scope() from ever being wired into dispatch.
|
||||
"hello": OperatorScope.READ,
|
||||
"join": OperatorScope.READ,
|
||||
"leave": OperatorScope.READ,
|
||||
"agent.message": OperatorScope.WRITE,
|
||||
"message": OperatorScope.WRITE,
|
||||
# Aborting a turn mutates it, so it carries the same scope as sending one.
|
||||
"abort": OperatorScope.WRITE,
|
||||
"session.status": OperatorScope.READ,
|
||||
"session.transcript": OperatorScope.READ,
|
||||
"approvals.resolve": OperatorScope.APPROVALS,
|
||||
|
||||
@@ -14,14 +14,6 @@ from .protocols import (
|
||||
is_guardrail_object,
|
||||
)
|
||||
from .chain import GuardrailChain
|
||||
from .retry import GuardrailRetry
|
||||
from .tool_guardrails import (
|
||||
ToolInputGuardrail,
|
||||
ToolOutputGuardrail,
|
||||
ToolGuardrailChain,
|
||||
build_tool_guardrails,
|
||||
get_tool_guardrail_chain,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GuardrailResult",
|
||||
@@ -30,12 +22,5 @@ __all__ = [
|
||||
"StructuralGuardrailProtocol",
|
||||
"PolicyGuardrailProtocol",
|
||||
"GuardrailChain",
|
||||
"GuardrailRetry",
|
||||
"is_guardrail_object",
|
||||
# Per-tool guardrails (declared via @tool(input_guardrails=/output_guardrails=))
|
||||
"ToolInputGuardrail",
|
||||
"ToolOutputGuardrail",
|
||||
"ToolGuardrailChain",
|
||||
"build_tool_guardrails",
|
||||
"get_tool_guardrail_chain",
|
||||
]
|
||||
@@ -6,7 +6,6 @@ separated from the protocol definitions for clean architecture.
|
||||
"""
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from .protocols import GuardrailProtocol
|
||||
from .retry import GuardrailRetry
|
||||
|
||||
|
||||
class GuardrailChain:
|
||||
@@ -79,22 +78,7 @@ class GuardrailChain:
|
||||
return False, arguments # Fail closed on error
|
||||
|
||||
return True, arguments
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
|
||||
"""Validate a tool result through all guardrails (fail-closed)."""
|
||||
for guardrail in self.guardrails:
|
||||
if hasattr(guardrail, 'validate_tool_result'):
|
||||
try:
|
||||
is_valid, processed_result = guardrail.validate_tool_result(tool_name, result, **kwargs)
|
||||
if not is_valid:
|
||||
return False, result # Failed validation
|
||||
result = processed_result # Update result with processing (e.g. redaction)
|
||||
except Exception as e:
|
||||
if not self.fail_open:
|
||||
return False, result # Fail closed on error
|
||||
|
||||
return True, result
|
||||
|
||||
|
||||
def _validate_through_chain(self, method_name: str, content: str, **kwargs) -> Tuple[bool, str]:
|
||||
"""Internal helper to validate content through the guardrail chain."""
|
||||
for guardrail in self.guardrails:
|
||||
@@ -105,11 +89,6 @@ class GuardrailChain:
|
||||
if not is_valid:
|
||||
return False, processed_content # Failed validation
|
||||
content = processed_content # Update content with processing
|
||||
except GuardrailRetry as e:
|
||||
# Deliberate "not acceptable, here is why" signal: surface the
|
||||
# author's message verbatim so it can be handed to the model,
|
||||
# instead of burying it under the generic error wrapper below.
|
||||
return False, e.feedback
|
||||
except Exception as e:
|
||||
if not self.fail_open:
|
||||
return False, f"Guardrail error: {str(e)}" # Fail closed on error
|
||||
|
||||
Loaded 100 of 1331 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user