mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29: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"
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Pick Claude OAuth token
|
||||
description: Probe primary/secondary Claude OAuth tokens and select a working one (dual OAuth Option B).
|
||||
inputs:
|
||||
token_primary:
|
||||
description: Primary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN)
|
||||
required: false
|
||||
token_secondary:
|
||||
description: Secondary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN_B)
|
||||
required: false
|
||||
active_slot:
|
||||
description: Preferred slot — primary or secondary (repo variable CLAUDE_OAUTH_ACTIVE)
|
||||
required: false
|
||||
default: primary
|
||||
outputs:
|
||||
token:
|
||||
description: Selected OAuth token
|
||||
value: ${{ steps.pick.outputs.token }}
|
||||
slot:
|
||||
description: Slot used (primary, secondary, or *-unprobed fallback)
|
||||
value: ${{ steps.pick.outputs.slot }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: pick
|
||||
shell: bash
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.token_primary }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ inputs.token_secondary }}
|
||||
CLAUDE_OAUTH_ACTIVE: ${{ inputs.active_slot }}
|
||||
run: |
|
||||
if [[ -n "${HUB_SCRIPTS:-}" && -f "${HUB_SCRIPTS}/oauth-pick-token.sh" ]]; then
|
||||
SCRIPT="${HUB_SCRIPTS}/oauth-pick-token.sh"
|
||||
else
|
||||
SCRIPT="${GITHUB_ACTION_PATH}/../../scripts/oauth-pick-token.sh"
|
||||
fi
|
||||
bash "$SCRIPT"
|
||||
@@ -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,53 +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
|
||||
);
|
||||
|
||||
// merge-only must not use a short time window — fresh Opus APPROVE after assess wins on HEAD.
|
||||
assert(
|
||||
'merge path finds APPROVE on HEAD without minCreatedAt window',
|
||||
mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
|
||||
null,
|
||||
'2026-06-12T09:00:00Z',
|
||||
{ excludeAutomatedFallback: true }
|
||||
) === 'APPROVE'
|
||||
);
|
||||
assert(
|
||||
'25min minCreatedAt excludes older APPROVE (scan/dispatch paths only)',
|
||||
mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' }],
|
||||
'2026-06-12T09:30:00Z',
|
||||
'2026-06-12T07:00:00Z',
|
||||
{ excludeAutomatedFallback: true }
|
||||
) === 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(
|
||||
@@ -304,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(
|
||||
@@ -324,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);
|
||||
@@ -349,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);
|
||||
+23
-233
@@ -16,12 +16,8 @@ const PR_MAX_AUTO_ADDITIONS = 800;
|
||||
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 EXTERNAL_EXAMPLE_PATH_PREFIX = 'examples/tools/external/';
|
||||
const EXTERNAL_EXAMPLE_LABEL = 'pipeline/external-example';
|
||||
const MAINTAINER_ACCEPT_EXAMPLE_LABEL = 'maintainer-accept-example';
|
||||
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)\//,
|
||||
@@ -30,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}/,
|
||||
@@ -318,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/\n6b. **External tool examples (`examples/tools/external/**`):** If the diff is confined to that path, mark **SDK value** ❌ for monorepo merge — redirect the contributor to PraisonAI-Tools; do **not** Approve for merge unless the PR has the `maintainer-accept-example` label.\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 = `
|
||||
@@ -487,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({
|
||||
@@ -586,11 +490,6 @@ function isCiOnlyChange(files) {
|
||||
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function isExternalExampleOnlyChange(files) {
|
||||
if (!files.length) return false;
|
||||
return files.every((f) => f.filename.startsWith(EXTERNAL_EXAMPLE_PATH_PREFIX));
|
||||
}
|
||||
|
||||
function isInternalPullRequestLink(link, owner, repo) {
|
||||
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
|
||||
const baseUrl = link?.base?.repo?.url || '';
|
||||
@@ -728,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;
|
||||
}
|
||||
|
||||
@@ -805,67 +687,22 @@ function manualReviewReasonForAgentPy(agentChange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber, options = {}) {
|
||||
const { since } = options;
|
||||
const params = {
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
};
|
||||
if (since) params.since = since;
|
||||
|
||||
// Always page explicitly — github-script's paginate helper can return only the
|
||||
// first page on busy PRs, so fresh MERGE_GATE_VERDICT comments on page 2+ are missed.
|
||||
const all = [];
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const { data } = await github.rest.issues.listComments({ ...params, page });
|
||||
all.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Poll until Opus posts MERGE_GATE_VERDICT on HEAD (merge-only runs right after assess). */
|
||||
async function waitForMergeGateVerdict(github, owner, repo, prNumber, options = {}) {
|
||||
const {
|
||||
headPushedAt = null,
|
||||
excludeAutomatedFallback = true,
|
||||
maxAttempts = 18,
|
||||
intervalMs = 10000,
|
||||
since = null,
|
||||
core: log = null,
|
||||
} = options;
|
||||
|
||||
let resolvedHeadPushedAt = headPushedAt;
|
||||
const commentSince =
|
||||
since ||
|
||||
(resolvedHeadPushedAt
|
||||
? new Date(new Date(resolvedHeadPushedAt).getTime() - 5 * 60 * 1000).toISOString()
|
||||
: null);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (!resolvedHeadPushedAt) {
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
resolvedHeadPushedAt = ctx.headPushedAt;
|
||||
}
|
||||
const comments = await listAllComments(github, owner, repo, prNumber, {
|
||||
since: commentSince || undefined,
|
||||
});
|
||||
const verdict = findMergeGateVerdict(comments, null, resolvedHeadPushedAt, {
|
||||
excludeAutomatedFallback,
|
||||
});
|
||||
if (verdict) {
|
||||
return { verdict, headPushedAt: resolvedHeadPushedAt };
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
log?.info?.(
|
||||
`Verdict not visible yet (attempt ${attempt}/${maxAttempts}), waiting ${intervalMs / 1000}s...`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
return { verdict: null, headPushedAt: resolvedHeadPushedAt };
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getHeadCommitDate(github, owner, repo, prNumber) {
|
||||
@@ -981,15 +818,6 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
|
||||
|
||||
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
|
||||
|
||||
if (
|
||||
isExternalExampleOnlyChange(pullFiles) &&
|
||||
!ctx.labels.includes(MAINTAINER_ACCEPT_EXAMPLE_LABEL)
|
||||
) {
|
||||
reasons.push(
|
||||
'external example only — belongs in PraisonAI-Tools (add maintainer-accept-example to opt in)'
|
||||
);
|
||||
}
|
||||
|
||||
const agentChange = getAgentPyChangeFromFiles(pullFiles);
|
||||
const agentManual = manualReviewReasonForAgentPy(agentChange);
|
||||
if (agentManual) reasons.push(agentManual);
|
||||
@@ -1036,13 +864,6 @@ async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
|
||||
}
|
||||
const mergeReady = mergeReadyIssues
|
||||
.filter((issue) => issue.pull_request)
|
||||
.filter((issue) => {
|
||||
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
||||
if (labels.includes(EXTERNAL_EXAMPLE_LABEL) && !labels.includes(MAINTAINER_ACCEPT_EXAMPLE_LABEL)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((issue) => issue.number)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
@@ -1098,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;
|
||||
@@ -1134,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;
|
||||
}
|
||||
|
||||
@@ -1170,10 +976,6 @@ module.exports = {
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isExternalExampleOnlyChange,
|
||||
EXTERNAL_EXAMPLE_PATH_PREFIX,
|
||||
EXTERNAL_EXAMPLE_LABEL,
|
||||
MAINTAINER_ACCEPT_EXAMPLE_LABEL,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
@@ -1192,7 +994,6 @@ module.exports = {
|
||||
secretScanReasons,
|
||||
sdkTestChecksReason,
|
||||
listAllComments,
|
||||
waitForMergeGateVerdict,
|
||||
getHeadCommitDate,
|
||||
isStaleFinalAfterPush,
|
||||
needsStaleFinalRecovery,
|
||||
@@ -1211,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,
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pick a working Claude Code OAuth token (primary/secondary with failover).
|
||||
# Env: CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B, CLAUDE_OAUTH_ACTIVE
|
||||
# Out: GITHUB_OUTPUT token, slot
|
||||
set -euo pipefail
|
||||
|
||||
PRIMARY="${CLAUDE_CODE_OAUTH_TOKEN:-}"
|
||||
SECONDARY="${CLAUDE_CODE_OAUTH_TOKEN_B:-}"
|
||||
ACTIVE="${CLAUDE_OAUTH_ACTIVE:-primary}"
|
||||
|
||||
ACTIVE_LC=$(echo "$ACTIVE" | tr '[:upper:]' '[:lower:]')
|
||||
case "$ACTIVE_LC" in
|
||||
secondary|b|2) PREFERRED=secondary ;;
|
||||
*) PREFERRED=primary ;;
|
||||
esac
|
||||
|
||||
probe_token() {
|
||||
local token=$1
|
||||
[[ -z "$token" ]] && return 1
|
||||
|
||||
local body='{"model":"claude-3-5-haiku-latest","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
|
||||
local tmp http_code body_resp
|
||||
|
||||
tmp=$(mktemp)
|
||||
http_code=$(curl -sS -o "$tmp" -w "%{http_code}" \
|
||||
-X POST "https://api.anthropic.com/v1/messages" \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" \
|
||||
-d "$body" 2>/dev/null || echo "000")
|
||||
|
||||
body_resp=$(cat "$tmp")
|
||||
rm -f "$tmp"
|
||||
|
||||
if [[ "$http_code" == "200" ]] || [[ "$http_code" == "400" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "401" ]] || [[ "$http_code" == "429" ]]; then
|
||||
echo "::warning::OAuth probe failed (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$body_resp" | grep -qiE 'revoked|invalid.*token|authentication|weekly limit|rate_limit|overloaded|billing'; then
|
||||
echo "::warning::OAuth probe rejected (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
try_token() {
|
||||
local slot=$1 token=$2
|
||||
[[ -z "$token" ]] && return 1
|
||||
if probe_token "$token"; then
|
||||
SELECTED_SLOT=$slot
|
||||
SELECTED_TOKEN=$token
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
SELECTED_SLOT=""
|
||||
SELECTED_TOKEN=""
|
||||
|
||||
if [[ "$PREFERRED" == "primary" ]]; then
|
||||
try_token primary "$PRIMARY" || try_token secondary "$SECONDARY" || true
|
||||
else
|
||||
try_token secondary "$SECONDARY" || try_token primary "$PRIMARY" || true
|
||||
fi
|
||||
|
||||
if [[ -z "$SELECTED_TOKEN" ]]; then
|
||||
if [[ "$PREFERRED" == "primary" && -n "$PRIMARY" ]]; then
|
||||
SELECTED_TOKEN="$PRIMARY"
|
||||
SELECTED_SLOT="primary-unprobed"
|
||||
elif [[ -n "$SECONDARY" ]]; then
|
||||
SELECTED_TOKEN="$SECONDARY"
|
||||
SELECTED_SLOT="secondary-unprobed"
|
||||
elif [[ -n "$PRIMARY" ]]; then
|
||||
SELECTED_TOKEN="$PRIMARY"
|
||||
SELECTED_SLOT="primary-unprobed"
|
||||
else
|
||||
echo "::error::No Claude OAuth token configured"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::OAuth probe failed for all tokens; using ${SELECTED_SLOT}"
|
||||
fi
|
||||
|
||||
echo "::notice::Claude OAuth slot: ${SELECTED_SLOT}"
|
||||
|
||||
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
|
||||
echo "slot=${SELECTED_SLOT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "token<<EOF"
|
||||
echo "$SELECTED_TOKEN"
|
||||
echo "EOF"
|
||||
echo "slot=${SELECTED_SLOT}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
@@ -73,33 +73,4 @@ assert(
|
||||
)
|
||||
);
|
||||
|
||||
assert(
|
||||
'external example only path',
|
||||
mg.isExternalExampleOnlyChange([{ filename: 'examples/tools/external/arxiv/tool.py' }])
|
||||
);
|
||||
assert(
|
||||
'mixed paths not external-example only',
|
||||
!mg.isExternalExampleOnlyChange([
|
||||
{ filename: 'examples/tools/external/foo.py' },
|
||||
{ filename: 'src/praisonai-agents/foo.py' },
|
||||
])
|
||||
);
|
||||
|
||||
assert(
|
||||
'upstream head repo',
|
||||
ps.isUpstreamHeadRepo(
|
||||
{ head: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork head repo still synced (not upstream)',
|
||||
!ps.isUpstreamHeadRepo(
|
||||
{ head: { repo: { full_name: 'ai-mrscraper/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -37,17 +37,8 @@ const LABEL_SPECS = [
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
{
|
||||
name: 'pipeline/external-example',
|
||||
color: 'bfd4f2',
|
||||
description: 'Diff only under examples/tools/external/ — not merge-ready without maintainer-accept-example',
|
||||
},
|
||||
];
|
||||
|
||||
// PRs with pipeline/external-example cannot reach pipeline/merge-ready unless
|
||||
// maintainers add maintainer-accept-example (enforced in merge-gate evaluatePipelineQuiescent,
|
||||
// same fail-closed pattern as fork PRs).
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
|
||||
@@ -72,8 +63,7 @@ function reasonToBlockerLabel(reason) {
|
||||
r.includes('without test') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret') ||
|
||||
r.includes('agent.py') ||
|
||||
r.includes('external example only')
|
||||
r.includes('agent.py')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
@@ -143,16 +133,11 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
const pullFiles = await mergeGate.listPullFiles(github, owner, repo, prNumber);
|
||||
const auxiliary = mergeGate.isExternalExampleOnlyChange(pullFiles)
|
||||
? [mergeGate.EXTERNAL_EXAMPLE_LABEL]
|
||||
: [];
|
||||
const labelSet = [...all, ...auxiliary];
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(labelSet);
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = labelSet.filter((l) => !current.includes(l));
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
@@ -196,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({
|
||||
@@ -214,11 +199,6 @@ async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandida
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isUpstreamHeadRepo(pr, owner, repo) {
|
||||
const headFull = pr.head?.repo?.full_name;
|
||||
return !headFull || headFull === `${owner}/${repo}`;
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
@@ -243,8 +223,9 @@ async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
for (const pr of prs) {
|
||||
if (synced >= maxPrs) break;
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (result.ready && isUpstreamHeadRepo(pr, owner, repo)) {
|
||||
if (result.ready) {
|
||||
readyCandidates.push({
|
||||
prNumber: pr.number,
|
||||
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
|
||||
@@ -267,7 +248,6 @@ module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
isUpstreamHeadRepo,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# Why this exists
|
||||
#
|
||||
# praisonai-agents has ~11,900 tests. Until this gate, CI executed 15 of them:
|
||||
# one smoke step in test-optimized.yml naming two files. Everything else was
|
||||
# collected by nobody.
|
||||
#
|
||||
# The cost of that showed up as two test modules that could not even be
|
||||
# IMPORTED -- they referenced symbols moved or replaced months earlier, so 19
|
||||
# tests silently contributed nothing while still appearing to exist. A failing
|
||||
# test is loud; a test that never collects is not.
|
||||
#
|
||||
# This job started as collection-only, because running the suite would have gone
|
||||
# red on arrival: 297 pre-existing failures nothing had been enforcing, and a job
|
||||
# that is red the day it lands teaches people to ignore it. Then a curated
|
||||
# green-set, ratcheted upward.
|
||||
#
|
||||
# Both are gone. The unit suite now runs in full and passes: 9373 tests, 0
|
||||
# failures. The `collect` job stays because it is the cheaper, stricter signal --
|
||||
# it cannot pass while a test file is unimportable, and it says so in 40s.
|
||||
#
|
||||
name: agents-suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'src/praisonai-agents/**'
|
||||
- '.github/workflows/agents-suite.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-agents/**'
|
||||
- '.github/workflows/agents-suite.yml'
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
name: every praisonai-agents test file imports
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e src/praisonai-agents
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- name: Collect the whole suite
|
||||
working-directory: src/praisonai-agents
|
||||
env:
|
||||
# A key must be present or some modules bail at import; it is never
|
||||
# used, because collection does not execute test bodies.
|
||||
OPENAI_API_KEY: 'sk-collect-only-not-a-real-key'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No pipe here: piping pytest into tail discards its exit code, which
|
||||
# is how a gate silently stops gating.
|
||||
python -m pytest tests/ --collect-only -q --disable-warnings
|
||||
|
||||
unit:
|
||||
name: praisonai-agents unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --upgrade pip
|
||||
# Extras, not decoration. Tests across workflows/embedding/eval/context
|
||||
# patch litellm and construct LLM(...), and the knowledge suite needs
|
||||
# chonkie for chunking -- without [knowledge] 22 tests fail on
|
||||
# "chonkie package not found", an install gap that reads as 22 code
|
||||
# failures.
|
||||
pip install -e "src/praisonai-agents[llm,knowledge]"
|
||||
# praisonai-sandbox is a MONOREPO sibling, not a third-party optional.
|
||||
# Without it 47 tests fail with "No module named
|
||||
# praisonai_sandbox.compute" -- an install gap reported as 47 code
|
||||
# failures. Installing it took the suite from 297 failures to 250.
|
||||
pip install -e src/praisonai-sandbox
|
||||
# Same story: a stale praisonai-bot in the environment made 22 gateway
|
||||
# tests fail on a malformed pairing file. The monorepo version already
|
||||
# handles it; the INSTALLED one did not.
|
||||
pip install -e src/praisonai-bot
|
||||
# The wrapper. tests/unit/test_managed_backend.py imports
|
||||
# praisonai.integrations.managed_agents; without it 47 tests fail with
|
||||
# "No module named 'praisonai'". Found by running the suite on 3.11 in
|
||||
# a CI-shaped environment rather than trusting a developer machine
|
||||
# where every sibling is already installed.
|
||||
pip install -e src/praisonai
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- name: Run the unit suite
|
||||
working-directory: src/praisonai-agents
|
||||
env:
|
||||
# Never used: PRAISONAI_ALLOW_NETWORK=0 and the live marker keep these
|
||||
# offline. Present because some modules bail at import without a key.
|
||||
OPENAI_API_KEY: 'sk-not-a-real-key'
|
||||
PRAISONAI_ALLOW_NETWORK: '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The whole suite, not a curated subset. tests/green-set.txt and
|
||||
# tests/known-failures.txt are gone: 9373 pass, 0 fail.
|
||||
#
|
||||
# The 297 failures this job was built around were, in the end:
|
||||
# 83 environment -- monorepo siblings not installed, dirty HOME
|
||||
# 19 ONE line -- a module-level os.environ.setdefault in
|
||||
# tests/unit/tools/test_delegation_tools.py that
|
||||
# turned auto-approval on for the whole process
|
||||
# at COLLECTION time
|
||||
# ~5 deprecation warnings that fire once per process
|
||||
# ~85 tests asserting contracts the product had deliberately changed
|
||||
# 5 real product bugs
|
||||
# the rest: tests built on Mocks whose auto-created attributes
|
||||
# defeated the very code path under test
|
||||
#
|
||||
# Three traps worth knowing before adding a test here, each of which
|
||||
# cost a full-suite bisect:
|
||||
# 1. Module-level env mutation persists for the entire process, and
|
||||
# runs at collection. Use monkeypatch.
|
||||
# 2. The approval registry is a process-global singleton. Anything
|
||||
# you add_requirement() stays for every later test.
|
||||
# 3. `'litellm' in module_name` also matches praisonai's own
|
||||
# _litellm_loader; evicting it from sys.modules creates a second
|
||||
# module object with separate globals.
|
||||
#
|
||||
# No pipe: piping pytest into anything discards its exit code, which is
|
||||
# how a gate silently stops gating.
|
||||
#
|
||||
# Nothing is excluded. test_prefetch.py used to be, recorded as "passes
|
||||
# on macOS, fails on Linux" -- wrong variable. It was Python 3.12 vs the
|
||||
# 3.11 pinned above: isinstance() against a runtime_checkable Protocol
|
||||
# used hasattr on 3.11 (which any MagicMock satisfies) and
|
||||
# inspect.getattr_static from 3.12 (which does not see Mock's
|
||||
# auto-created attributes). The mock now names its methods, so both
|
||||
# versions take the same branch.
|
||||
python -m pytest tests/unit/ -q -p no:randomly --timeout=120 \
|
||||
--disable-warnings
|
||||
@@ -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'
|
||||
});
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Post Claude FINAL after prior reviewers
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Post Claude FINAL (skip Copilot)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -275,7 +275,7 @@ jobs:
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.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'));
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
|
||||
@@ -28,16 +28,8 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Pick Claude OAuth token
|
||||
id: oauth
|
||||
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
|
||||
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
|
||||
|
||||
- name: Run Claude Issue Triage
|
||||
uses: ./.github/actions/claude-issue-triage-action
|
||||
with:
|
||||
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -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
|
||||
@@ -267,14 +253,6 @@ jobs:
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
|
||||
- name: Pick Claude OAuth token
|
||||
id: oauth
|
||||
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
|
||||
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
@@ -284,14 +262,13 @@ 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 }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
@@ -311,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).
|
||||
@@ -326,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 }}
|
||||
@@ -432,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}`));
|
||||
}
|
||||
@@ -454,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 }}
|
||||
@@ -473,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()
|
||||
@@ -501,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: |
|
||||
@@ -555,12 +452,10 @@ jobs:
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const { verdict } = await mergeGate.waitForMergeGateVerdict(
|
||||
github, owner, repo, prNumber, {
|
||||
headPushedAt: ctx.headPushedAt,
|
||||
excludeAutomatedFallback: true,
|
||||
core,
|
||||
}
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
|
||||
@@ -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,
|
||||
@@ -249,14 +248,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Pick Claude OAuth token
|
||||
id: oauth
|
||||
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
|
||||
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
env:
|
||||
@@ -283,7 +274,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: "@claude"
|
||||
@@ -302,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):
|
||||
@@ -314,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.
|
||||
@@ -346,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
|
||||
|
||||
@@ -363,8 +351,6 @@ jobs:
|
||||
Bash(conda:*)
|
||||
Bash(pytest:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(node:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(poetry:*)
|
||||
@@ -384,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
|
||||
@@ -21,9 +21,6 @@ on:
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cross-repo-integration:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -44,13 +41,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip
|
||||
- uses: ./PraisonAI/.github/actions/install-monorepo-packages
|
||||
- uses: PraisonAI/.github/actions/install-monorepo-packages
|
||||
with:
|
||||
repo-root: PraisonAI/
|
||||
wrapper-extras: ".[ui,dev]"
|
||||
@@ -141,13 +138,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip
|
||||
- uses: ./PraisonAI/.github/actions/install-monorepo-packages
|
||||
- uses: PraisonAI/.github/actions/install-monorepo-packages
|
||||
with:
|
||||
repo-root: PraisonAI/
|
||||
wrapper-extras: ".[ui,dev]"
|
||||
@@ -165,4 +162,4 @@ jobs:
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
fi
|
||||
@@ -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,6 +1,6 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open PRs (fork heads included; merge gate only for upstream branches).
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
@@ -16,6 +16,8 @@ jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
secrets.PRAISONAI_APP_ID != '' &&
|
||||
secrets.PRAISONAI_APP_PRIVATE_KEY != '' &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -30,28 +32,7 @@ jobs:
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Check reviewer configuration
|
||||
id: config
|
||||
env:
|
||||
HAS_APP_CREDENTIALS: ${{ secrets.PRAISONAI_APP_ID != '' && secrets.PRAISONAI_APP_PRIVATE_KEY != '' }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
echo "configured=$HAS_APP_CREDENTIALS" >> "$GITHUB_OUTPUT"
|
||||
openai_ready=true
|
||||
if [ -z "$OPENAI_API_KEY" ] || [ "$OPENAI_API_KEY" = "not-needed" ]; then
|
||||
openai_ready=false
|
||||
if [ "$HAS_APP_CREDENTIALS" = "true" ]; then
|
||||
echo "::notice::Skipping PraisonAI PR review: OPENAI_API_KEY is missing or a placeholder."
|
||||
fi
|
||||
fi
|
||||
if [ "$HAS_APP_CREDENTIALS" = "true" ] && [ "$openai_ready" = "true" ]; then
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine checkout ref
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
id: dest
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
@@ -67,7 +48,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout Repository
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -75,7 +55,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
@@ -83,17 +62,14 @@ jobs:
|
||||
private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PraisonAI
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
run: pip install "praisonai[all]"
|
||||
|
||||
- name: Run PraisonAI PR Review
|
||||
if: steps.config.outputs.ready == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
|
||||
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
|
||||
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
|
||||
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
run: |
|
||||
@@ -35,11 +34,8 @@ jobs:
|
||||
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
|
||||
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
|
||||
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN_B:-}" ]; then
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN_B" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_B --repo "$TARGET"
|
||||
fi
|
||||
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
|
||||
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
|
||||
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
|
||||
fi
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B (if set), GH_TOKEN → $TARGET"
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
|
||||
@@ -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})."
|
||||
@@ -21,10 +21,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
# harbor requires >=3.12; on 3.11 `pip install harbor` resolved to no
|
||||
# candidate version at all and every scheduled run died in ~15s, so
|
||||
# this gate has never produced a Terminal-Bench score.
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Harbor + PraisonAI
|
||||
run: |
|
||||
@@ -37,7 +34,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
harbor run -c examples/terminal_bench/job_code_smoke.yaml
|
||||
harbor run -c examples/terminal_bench/job_code_smoke.yaml || true
|
||||
|
||||
- name: Upload Harbor results
|
||||
if: always()
|
||||
|
||||
@@ -31,13 +31,6 @@ env:
|
||||
PRAISONAI_ALLOW_NETWORK: '0'
|
||||
PRAISONAI_LIVE_TESTS: '0'
|
||||
PRAISONAI_TEST_TIER: main
|
||||
# litellm reads this at import and, without it, downloads its model-price
|
||||
# table from raw.githubusercontent.com before a single test runs -- which
|
||||
# made the suite depend on that host being reachable. It has to be set in
|
||||
# the environment rather than a conftest, because pytest imports plugins
|
||||
# (and litellm with them) before any conftest is loaded.
|
||||
LITELLM_LOCAL_MODEL_COST_MAP: 'True'
|
||||
HF_HUB_OFFLINE: '1'
|
||||
|
||||
jobs:
|
||||
test-core-collect:
|
||||
@@ -98,78 +91,18 @@ jobs:
|
||||
extra_ignore: --ignore=tests/unit/cli/test_message_queue.py
|
||||
- shard: bots-gateway
|
||||
workdir: src/praisonai-bot
|
||||
# tests/unit/test_bots_cli.py sat at the top level and was collected
|
||||
# by no workflow at all, which is why a TypeError that made
|
||||
# `praisonai bot slack` unstartable shipped unnoticed. Named
|
||||
# explicitly rather than adding the whole top-level directory, which
|
||||
# carries suites with pre-existing env-dependent failures.
|
||||
# tests/unit/kanban/ was likewise collected by no workflow, which
|
||||
# is how six declared KANBAN_TASK_* hook events shipped with no
|
||||
# emitter at all.
|
||||
paths: >-
|
||||
tests/unit/bots/
|
||||
tests/unit/gateway/
|
||||
tests/unit/cli/
|
||||
tests/unit/daemon/
|
||||
tests/unit/kanban/
|
||||
tests/unit/test_bots_cli.py
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
- shard: agents-core
|
||||
workdir: src/praisonai-agents
|
||||
# src/praisonai-agents/tests holds 727 files and 11,437 test
|
||||
# functions, and until now NO workflow referenced the directory at
|
||||
# all -- so the LLM layer, the provider adapters, the local-model
|
||||
# package and the streaming paths were guarded by tests that never
|
||||
# ran. Nearly every defect found in the recent local-model audits
|
||||
# had a working test sitting unused beside it.
|
||||
#
|
||||
# The ten directories originally left out of this shard (knowledge,
|
||||
# runtime, session, tools, bus, plugins, skills, agents, rag,
|
||||
# streaming -- about 52 failures) have since been repaired, so they
|
||||
# are all listed below: 1,567 tests in the seven added here alone.
|
||||
# Leaving them out would have meant the repairs were never exercised.
|
||||
#
|
||||
# The whole tree in one invocation was hanging. The cause was NOT
|
||||
# the pytest-rerunfailures run_server thread guessed at here
|
||||
# earlier: it is test_botos_integration.py, where a supervised
|
||||
# Bot.start() deliberately never returns and the mock adapter
|
||||
# reports is_running=True forever (fixed on this branch). Naming
|
||||
# directories explicitly is still the safer shape for a shard.
|
||||
paths: >-
|
||||
tests/unit/agent/ tests/unit/app/ tests/unit/approval/
|
||||
tests/unit/auth/ tests/unit/background/ tests/unit/checkpoints/
|
||||
tests/unit/cli/ tests/unit/compaction/ tests/unit/conditions/
|
||||
tests/unit/config/ tests/unit/context/ tests/unit/embedding/
|
||||
tests/unit/eval/ tests/unit/gateway/ tests/unit/goal/
|
||||
tests/unit/hooks/ tests/unit/llm/ tests/unit/lsp/ tests/unit/mcp/
|
||||
tests/unit/memory/ tests/unit/permissions/ tests/unit/policy/
|
||||
tests/unit/process/ tests/unit/runs/ tests/unit/sandbox/
|
||||
tests/unit/security/ tests/unit/server/ tests/unit/snapshot/
|
||||
tests/unit/storage/ tests/unit/task/ tests/unit/telemetry/
|
||||
tests/unit/trace/ tests/unit/utils/ tests/unit/workflows/
|
||||
tests/unit/workspace/ tests/unit/knowledge/ tests/unit/session/
|
||||
tests/unit/plugins/ tests/unit/runtime/ tests/unit/tools/
|
||||
tests/unit/bus/ tests/unit/skills/ tests/unit/agents/
|
||||
tests/unit/rag/ tests/unit/streaming/
|
||||
tests/unit/test_adapter_registry.py
|
||||
# Both tests previously deselected here now pass in combination.
|
||||
# test_lazy_litellm_import was looking the loader module up by
|
||||
# name and could get a different object than the one get_litellm()
|
||||
# mutates; it reads the function's own __globals__ now.
|
||||
# test_skills_discovery_uses_paths_module read the real home
|
||||
# directory and asserted every discovered dir was named "skills",
|
||||
# which is false once a remote skill has ever been cached; it runs
|
||||
# against a temp HOME and asserts the paths module is used.
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents
|
||||
- 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,128 +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.
|
||||
# No leading "v": install.sh passes ?version=$OLLAMA_VERSION and ollama.com
|
||||
# redirects to GitHub tag v$version (vv0.33.2 → 404 if set to v0.33.2).
|
||||
OLLAMA_VERSION: "0.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)
|
||||
@@ -958,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
-8
@@ -26,9 +26,6 @@ output
|
||||
!src/praisonai-code/praisonai_code/cli/output/
|
||||
!src/praisonai-bot/praisonai_bot/cli/output/
|
||||
!src/praisonai-ts/src/db/
|
||||
# The bare `db` rule above also swallows the SQLite adapter's test
|
||||
# directory, so the durability suite would silently never be committed.
|
||||
!src/praisonai-ts/tests/unit/db/
|
||||
!src/praisonai-ts/src/cli/output/
|
||||
|
||||
.files
|
||||
@@ -118,8 +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/
|
||||
.worktrees/
|
||||
*.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`
|
||||
@@ -12,6 +12,7 @@
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://static.pepy.tech/badge/PraisonAI" alt="Total Downloads" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/github/v/release/MervinPraison/PraisonAI" alt="Latest Stable Version" /></a>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
|
||||
<a href="https://registry.modelcontextprotocol.io/servers/io.github.MervinPraison/praisonai"><img src="https://img.shields.io/badge/MCP-Registry-blue" alt="MCP Registry" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -34,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">
|
||||
@@ -1111,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>
|
||||
|
||||
@@ -53,11 +53,9 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_recommended_stage</a>(prompt: str) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_rules_context</a>(file_path: Optional[str] = None, include_manual: Optional[List[str]] = None) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_skills_prompt</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">guardrail_retry_count</a>() -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">last_guardrail_error</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">last_stop_reason</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">list_mcp_servers</a>() -> List[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
|
||||
@@ -70,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>
|
||||
@@ -195,7 +191,6 @@ Methods:
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get</a>(name: str) -> Optional[Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_all</a>() -> Dict[str, Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_tool_definition</a>(name: str) -> Optional[Dict[str, Any]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_tool_definitions</a>(permission_resolver: Optional[Callable[[str], bool]] = None) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_trust_level</a>(name: str) -> Optional[str]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None, ttl_seconds: Optional[float] = None) -> List[Union[BaseTool, Callable]]</code>
|
||||
@@ -272,14 +267,14 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_user_memory</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search</a>(query: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_entity</a>(query: str, limit: int = 5) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_long_term</a>(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, min_trust = None, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_short_term</a>(query: str, limit: int = 5, min_quality: float = 0.0, relevance_cutoff: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, min_trust = None, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_long_term</a>(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_short_term</a>(query: str, limit: int = 5, min_quality: float = 0.0, relevance_cutoff: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_user_memory</a>(user_id: str, query: str, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_with_quality</a>(query: str, min_quality: float = 0.0, memory_type: Literal['short', 'long'] = 'long', limit: int = 5) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_entity</a>(name: str, type_: str, desc: str, relations: str)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_long_term</a>(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None, trust = None, origin: Optional[str] = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_long_term</a>(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_quality</a>(text: str, quality_score: float, task_id: Optional[str] = None, iteration: Optional[int] = None, metrics: Optional[Dict[str, float]] = None, memory_type: Literal['short', 'long'] = 'long') -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_short_term</a>(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None, trust = None, origin: Optional[str] = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_short_term</a>(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_user_memory</a>(user_id: str, text: str, extra: Dict[str, Any] = None)</code>
|
||||
* <code title="class MemoryConfig">MemoryConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -310,9 +305,9 @@ Methods:
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">markdown</a>()</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">memory</a>()</code>
|
||||
* <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>() -> bool</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>
|
||||
|
||||
@@ -390,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
|
||||
|
||||
@@ -714,11 +708,8 @@ Methods:
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">reasoning</a>() -> bool</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">run</a>(input: str = '', llm: Optional[str] = None, verbose: bool = False, stream: bool = None) -> Dict[str, Any]</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">start</a>(input: str = '', **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">state</a>()</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">stream</a>() -> bool</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_mermaid</a>() -> str</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">validate_variables</a>() -> None</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>(value: bool)</code>
|
||||
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">where_does_it_run</a>() -> str</code>
|
||||
@@ -752,7 +743,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_spawned_agents</a>() -> List[SpawnedSubAgent]</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_state</a>(key: str, default: Any = None) -> Any</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_details</a>(task_id)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_result</a>(task_id, tasks = None)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_result</a>(task_id)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_status</a>(task_id)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_todo_markdown</a>() -> str</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
|
||||
@@ -776,7 +767,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">where_does_it_run</a>() -> str</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
|
||||
@@ -788,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
|
||||
@@ -913,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>
|
||||
@@ -932,24 +922,17 @@ Methods:
|
||||
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createSubprocessExecutor, 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 { reviewTaskOutput } from "./agent/task-review";
|
||||
export { // Agent loop
|
||||
createAgentLoop, // DevTools
|
||||
enableDevTools, // MCP
|
||||
@@ -966,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
|
||||
@@ -985,31 +967,26 @@ export { // Autonomy Mode
|
||||
SandboxExecutor, // Scheduler
|
||||
Scheduler, // Slash Commands
|
||||
SlashCommandHandler, AiderAgent, ClaudeCodeAgent, CodexCliAgent, CommandValidator, CostTokenUsage, DEFAULT_BLOCKED_COMMANDS, DEFAULT_BLOCKED_PATHS, DEFAULT_IGNORE_PATTERNS, DiffViewer, FileCheckpointStorage, FileJobStorage, GeminiCliAgent, GenericExternalAgent, HistoryManager, MODEL_PRICING, MODE_POLICIES, MemoryCheckpointStorage, MemoryJobStorage, StatusDisplay, addLineRangeToFileMatch, cliApprovalPrompt, createAutonomyManager, createCheckpointManager, createCostTracker, createDiffViewer, createExternalAgent, createFastContext, createFileCheckpointStorage, createFileJobStorage, createFileMatch, createFlowDisplay, createGitManager, createHistoryManager, createInteractiveTUI, createJobQueue, createLineRange, createN8NIntegration, createRepoMap, createSandboxExecutor, createScheduler, createSlashCommandHandler, createStatusDisplay, cronExpressions, estimateTokens, executeSlashCommand, externalAgentAsTool, formatCost, getExternalAgentRegistry, getLineCount, getQuickContext, getRepoTree, getTotalLines, isSlashCommand, mergeRanges, parseSlashCommand, rangesOverlap, registerCommand, renderWorkflow, sandboxExec, triggerN8NWebhook } from "./cli/features";
|
||||
export { ComputeError, DockerCompute, LocalCompute, listComputeProviders, registerComputeProvider, resolveComputeProvider } from "./compute";
|
||||
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";
|
||||
@@ -1018,8 +995,7 @@ export { // Bot types
|
||||
FailoverManager, // Enums
|
||||
SandboxStatus, // Gateway types
|
||||
type GatewayConfig, // Other types
|
||||
type ProviderStatus, // Session-projection reducer (Issue #5324)
|
||||
SessionProjection, 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
|
||||
@@ -1031,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
|
||||
@@ -1043,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, ModelRequestBlocked, ScriptExhausted, ScriptedModel, allowModelRequests, listHarnessProfiles, modelRequestsAllowed, noModelRequests, 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
|
||||
@@ -1066,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";
|
||||
@@ -1073,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.11" \
|
||||
"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.11" \
|
||||
"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.11" \
|
||||
"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__":
|
||||
|
||||
@@ -140,4 +140,4 @@ Key fixes applied:
|
||||
4. String-based LLM guardrails are also supported
|
||||
|
||||
The guardrail will automatically retry (up to max_retries times) if validation fails.
|
||||
""")
|
||||
""")
|
||||
+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)
|
||||
@@ -1,28 +0,0 @@
|
||||
"""OpenRouter video generation via VideoAgent.
|
||||
|
||||
Requires OPENROUTER_API_KEY. Optionally set OPENROUTER_REFERER and
|
||||
OPENROUTER_APP_TITLE for OpenRouter dashboard attribution.
|
||||
|
||||
The openrouter/ prefix is required so the request routes to OpenRouter.
|
||||
Discover exact model ids at https://openrouter.ai/api/v1/videos/models.
|
||||
"""
|
||||
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="openrouter/google/veo-3.1")
|
||||
|
||||
video = agent.generate(
|
||||
"A serene mountain landscape at sunset, locked camera",
|
||||
duration=6,
|
||||
resolution="720p",
|
||||
aspect_ratio="16:9",
|
||||
generate_audio=False,
|
||||
)
|
||||
print(f"Video ID: {video.id}")
|
||||
|
||||
completed = agent.wait_for_completion(video.id)
|
||||
if completed.status == "completed":
|
||||
agent.download(video.id, "openrouter_veo.mp4")
|
||||
print("Saved openrouter_veo.mp4")
|
||||
else:
|
||||
print(f"Generation ended with status: {completed.status}")
|
||||
@@ -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
|
||||
@@ -16,13 +16,9 @@ Architecture:
|
||||
Harbor Container → `praisonai code -p --output json "TASK" --dangerously-skip-approval`
|
||||
|
||||
Notes:
|
||||
- `--dangerously-skip-approval` registers an always-approve backend on the
|
||||
approval registry (plus PRAISON_APPROVAL_MODE=auto /
|
||||
PRAISONAI_TOOL_SAFETY=off) so the assistant runs fully autonomously in the
|
||||
container. Before that registry wiring existed the flag left every
|
||||
critical-tool prompt in place and the non-TTY container auto-denied all of
|
||||
them, so the agent could not run a single shell command while still
|
||||
exiting 0 -- one cause of the 0.000 scores in #3932.
|
||||
- `--dangerously-skip-approval` sets PRAISON_APPROVAL_MODE=auto +
|
||||
PRAISONAI_TOOL_SAFETY=off so the assistant runs fully autonomously in the
|
||||
container (no approval hang in a non-TTY session).
|
||||
- The JSON envelope provides token, cost, session, and status metadata while
|
||||
the real exit status still surfaces install/auth/startup failures.
|
||||
- The base `praisonai` package is sufficient; heavy `code` extras are not
|
||||
|
||||
@@ -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)
|
||||
@@ -27,10 +27,11 @@ mcp:
|
||||
enabled: true
|
||||
|
||||
time:
|
||||
# Time and date utilities (published Python MCP server via uvx)
|
||||
command: uvx
|
||||
# Time and date utilities
|
||||
command: npx
|
||||
args:
|
||||
- "mcp-server-time"
|
||||
- "-y"
|
||||
- "@modelcontextprotocol/server-time"
|
||||
env: {}
|
||||
enabled: true
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"short_term_limit": 100,
|
||||
"long_term_limit": 1000,
|
||||
"episodic_retention_days": 30,
|
||||
"enable_summarization": false,
|
||||
"summarization_threshold": 50,
|
||||
"importance_threshold": 0.7,
|
||||
"auto_promote": true
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
|
||||
"total_files": 0,
|
||||
"total_tokens": 11,
|
||||
"levels": 3,
|
||||
"built_at": "2026-02-02T17:47:19.241146",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
|
||||
"level": 3,
|
||||
"summary": "Project with 0 folders and 0 root files: ",
|
||||
"children": [],
|
||||
"token_count": 11,
|
||||
"created_at": "2026-02-02T17:47:19.241224",
|
||||
"metadata": {
|
||||
"folder_count": 0,
|
||||
"root_file_count": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm",
|
||||
"total_files": 1,
|
||||
"total_tokens": 16,
|
||||
"levels": 2,
|
||||
"built_at": "2026-02-02T17:47:19.243732",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py",
|
||||
"level": 1,
|
||||
"summary": "# Application code",
|
||||
"children": [],
|
||||
"token_count": 5,
|
||||
"created_at": "2026-02-02T17:47:19.243833",
|
||||
"metadata": {
|
||||
"filename": "app.py",
|
||||
"relative_path": "src/app.py"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src",
|
||||
"level": 2,
|
||||
"summary": "Folder contains 1 files: # Application code",
|
||||
"children": [
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py"
|
||||
],
|
||||
"token_count": 11,
|
||||
"created_at": "2026-02-02T17:47:19.243852",
|
||||
"metadata": {
|
||||
"folder_name": "src",
|
||||
"file_count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
|
||||
"total_files": 0,
|
||||
"total_tokens": 11,
|
||||
"levels": 3,
|
||||
"built_at": "2026-02-02T17:49:37.074095",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
|
||||
"level": 3,
|
||||
"summary": "Project with 0 folders and 0 root files: ",
|
||||
"children": [],
|
||||
"token_count": 11,
|
||||
"created_at": "2026-02-02T17:49:37.074201",
|
||||
"metadata": {
|
||||
"folder_count": 0,
|
||||
"root_file_count": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj",
|
||||
"total_files": 2,
|
||||
"total_tokens": 14,
|
||||
"levels": 1,
|
||||
"built_at": "2026-02-02T17:47:19.242486",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py",
|
||||
"level": 1,
|
||||
"summary": "def helper(): return True",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:47:19.242582",
|
||||
"metadata": {
|
||||
"filename": "utils.py",
|
||||
"relative_path": "utils.py"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py",
|
||||
"level": 1,
|
||||
"summary": "def main(): print('Hello')",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:47:19.242630",
|
||||
"metadata": {
|
||||
"filename": "main.py",
|
||||
"relative_path": "main.py"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar",
|
||||
"total_files": 1,
|
||||
"total_tokens": 16,
|
||||
"levels": 2,
|
||||
"built_at": "2026-02-02T17:49:37.077857",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py",
|
||||
"level": 1,
|
||||
"summary": "# Application code",
|
||||
"children": [],
|
||||
"token_count": 5,
|
||||
"created_at": "2026-02-02T17:49:37.077985",
|
||||
"metadata": {
|
||||
"filename": "app.py",
|
||||
"relative_path": "src/app.py"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src",
|
||||
"level": 2,
|
||||
"summary": "Folder contains 1 files: # Application code",
|
||||
"children": [
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py"
|
||||
],
|
||||
"token_count": 11,
|
||||
"created_at": "2026-02-02T17:49:37.078011",
|
||||
"metadata": {
|
||||
"folder_name": "src",
|
||||
"file_count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4",
|
||||
"total_files": 2,
|
||||
"total_tokens": 14,
|
||||
"levels": 1,
|
||||
"built_at": "2026-02-02T17:49:37.075925",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py",
|
||||
"level": 1,
|
||||
"summary": "def helper(): return True",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:49:37.076036",
|
||||
"metadata": {
|
||||
"filename": "utils.py",
|
||||
"relative_path": "utils.py"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py",
|
||||
"level": 1,
|
||||
"summary": "def main(): print('Hello')",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:49:37.076227",
|
||||
"metadata": {
|
||||
"filename": "main.py",
|
||||
"relative_path": "main.py"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831",
|
||||
"total_files": 2,
|
||||
"total_tokens": 16,
|
||||
"levels": 1,
|
||||
"built_at": "2026-02-02T17:49:37.079504",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js",
|
||||
"level": 1,
|
||||
"summary": "JavaScript web development",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:49:37.079612",
|
||||
"metadata": {
|
||||
"filename": "javascript.js",
|
||||
"relative_path": "javascript.js"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py",
|
||||
"level": 1,
|
||||
"summary": "Python programming language code",
|
||||
"children": [],
|
||||
"token_count": 9,
|
||||
"created_at": "2026-02-02T17:49:37.079658",
|
||||
"metadata": {
|
||||
"filename": "python.py",
|
||||
"relative_path": "python.py"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7",
|
||||
"total_files": 2,
|
||||
"total_tokens": 16,
|
||||
"levels": 1,
|
||||
"built_at": "2026-02-02T17:47:19.245229",
|
||||
"nodes": {
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js",
|
||||
"level": 1,
|
||||
"summary": "JavaScript web development",
|
||||
"children": [],
|
||||
"token_count": 7,
|
||||
"created_at": "2026-02-02T17:47:19.245344",
|
||||
"metadata": {
|
||||
"filename": "javascript.js",
|
||||
"relative_path": "javascript.js"
|
||||
}
|
||||
},
|
||||
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py": {
|
||||
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py",
|
||||
"level": 1,
|
||||
"summary": "Python programming language code",
|
||||
"children": [],
|
||||
"token_count": 9,
|
||||
"created_at": "2026-02-02T17:47:19.245395",
|
||||
"metadata": {
|
||||
"filename": "python.py",
|
||||
"relative_path": "python.py"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -514,7 +495,7 @@ agent = Agent(
|
||||
name="assistant",
|
||||
llm="gpt-4o-mini",
|
||||
memory=MemoryConfig(provider="chroma", use_long_term=True),
|
||||
hooks=HooksConfig(on_tool_call=my_tool_logger, middleware=[my_validator]),
|
||||
hooks=HooksConfig(before_tool=[my_validator]),
|
||||
guardrail=my_guardrail
|
||||
)
|
||||
```
|
||||
@@ -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,79 +17,78 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 59 | 19076 |
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 53 | 17053 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
|
||||
| ✅ Approval | 1 | 8 | 3969 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2658 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3941 |
|
||||
| ✅ Autonomy | 3 | 7 | 2805 |
|
||||
| ✅ Bots | 7 | 38 | 15809 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3542 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 31 | 13411 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 124 | 39191 |
|
||||
| ✅ Chunking | 2 | 2 | 435 |
|
||||
| ✅ CLI | 5 | 122 | 36734 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 5022 |
|
||||
| ✅ Conditions | 1 | 3 | 1386 |
|
||||
| ✅ Configuration | 3 | 8 | 3341 |
|
||||
| ✅ Context Management | 16 | 36 | 14384 |
|
||||
| ✅ Deep Research | 8 | 2 | 591 |
|
||||
| ✅ Display | 6 | 3 | 844 |
|
||||
| ✅ Embeddings | 6 | 23 | 2051 |
|
||||
| ✅ Evaluation | 1 | 9 | 3637 |
|
||||
| ✅ Events | 1 | 2 | 782 |
|
||||
| ✅ Execution | 3 | 4 | 1694 |
|
||||
| ✅ Failover | 2 | 1 | 537 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Flow | 1 | 3 | 811 |
|
||||
| ✅ Gateway | 7 | 97 | 37597 |
|
||||
| ✅ Guardrails | 5 | 4 | 2642 |
|
||||
| ✅ Handoffs | 11 | 6 | 2748 |
|
||||
| ✅ Hooks | 2 | 9 | 5230 |
|
||||
| ✅ Image | 1 | 11 | 1295 |
|
||||
| ✅ Knowledge | 4 | 20 | 6260 |
|
||||
| ✅ LLM | 3 | 15 | 5958 |
|
||||
| ✅ Loops | 4 | 5 | 1478 |
|
||||
| ✅ MCP | 1 | 64 | 15116 |
|
||||
| ✅ Memory | 6 | 21 | 8198 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3139 |
|
||||
| ✅ Context Management | 16 | 36 | 13988 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ 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 | 80 | 29490 |
|
||||
| ✅ Guardrails | 4 | 4 | 2205 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4488 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 16 | 4942 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 61 | 14135 |
|
||||
| ✅ Memory | 6 | 18 | 7187 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2859 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1289 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 528 |
|
||||
| ✅ Planning | 6 | 7 | 1827 |
|
||||
| ✅ Plugins | 8 | 5 | 4108 |
|
||||
| ✅ Prompts | 2 | 9 | 2043 |
|
||||
| ✅ Providers | 1 | 58 | 8751 |
|
||||
| ✅ Output | 3 | 5 | 1265 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 6 | 1605 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7781 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3152 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 732 |
|
||||
| ✅ Retrieval | 2 | 5 | 1046 |
|
||||
| ✅ Reflection | 3 | 3 | 730 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3720 |
|
||||
| ✅ Security | 1 | 3 | 3642 |
|
||||
| ✅ Sessions | 4 | 21 | 8388 |
|
||||
| ✅ Skills | 6 | 16 | 6045 |
|
||||
| ✅ Tasks | 2 | 8 | 3266 |
|
||||
| ✅ Telemetry | 1 | 2 | 660 |
|
||||
| ✅ Templates | 1 | 9 | 1859 |
|
||||
| ✅ Tools | 12 | 147 | 45378 |
|
||||
| ✅ 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 | 10 | 1303 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 10 | 2822 |
|
||||
| ✅ Workflows | 5 | 21 | 8880 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (44 docs, 8206 lines)
|
||||
- ℹ️ Documents (1 docs, 810 lines)
|
||||
- ℹ️ Teams (1 docs, 214 lines)
|
||||
- ℹ️ Database (42 docs, 6959 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Root conftest: environment that must be set before third-party imports.
|
||||
|
||||
pytest loads plugins registered through entry points before it loads
|
||||
``tests/conftest.py``, and something in that chain imports litellm. litellm
|
||||
reads LITELLM_LOCAL_MODEL_COST_MAP at *its* import and, without it, fetches
|
||||
model_prices_and_context_window.json from raw.githubusercontent.com -- so the
|
||||
suite made an outbound request before a single test ran, and stalled or failed
|
||||
when offline. A rootdir conftest is loaded early enough to win that race.
|
||||
|
||||
Anything that merely needs to be set before the tests themselves belongs in
|
||||
tests/conftest.py instead.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
@@ -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,303 +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.
|
||||
# No leading "v": install.sh passes ?version=$OLLAMA_VERSION and ollama.com
|
||||
# redirects to GitHub tag v$version (vv0.33.2 → 404 if set to v0.33.2).
|
||||
OLLAMA_VERSION: "0.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
|
||||
# The official installer, VERSION-PINNED via OLLAMA_VERSION (which it
|
||||
# honours). Since v0.33 the linux bundle ships as a .tar.zst with GPU
|
||||
# runtimes bundled in, so a direct release-asset download is not a
|
||||
# workable alternative; the installer handles the zst/tgz split.
|
||||
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 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,23 +133,12 @@ _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'),
|
||||
'TerminationReason': ('praisonaiagents.run_outcome', 'TerminationReason'),
|
||||
'termination_to_run_status': ('praisonaiagents.run_outcome', 'termination_to_run_status'),
|
||||
'validate_decision_string': ('praisonaiagents.run_outcome', 'validate_decision_string'),
|
||||
'RunTerminal': ('praisonaiagents.run_outcome', 'RunTerminal'),
|
||||
'merge_run_terminal': ('praisonaiagents.run_outcome', 'merge_run_terminal'),
|
||||
'is_sticky': ('praisonaiagents.run_outcome', 'is_sticky'),
|
||||
'collapse': ('praisonaiagents.run_outcome', 'collapse'),
|
||||
|
||||
# Main display utilities (imports rich)
|
||||
'TaskOutput': ('praisonaiagents.main', 'TaskOutput'),
|
||||
@@ -231,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'),
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides feature flags and configuration options that can be controlled
|
||||
via environment variables for performance tuning and behavior control.
|
||||
|
||||
Environment Variables:
|
||||
PRAISONAI_LAZY_IMPORTS: Enable lazy imports (default: true)
|
||||
PRAISONAI_TELEMETRY_ENABLED: Enable telemetry (default: false, opt-in)
|
||||
PRAISONAI_TELEMETRY_DISABLED: Disable telemetry (takes precedence)
|
||||
DO_NOT_TRACK: Standard opt-out flag (takes precedence)
|
||||
@@ -27,6 +28,13 @@ def _str_to_bool(value: Optional[str], default: bool = False) -> bool:
|
||||
# Feature Flags
|
||||
# -------------
|
||||
|
||||
# Lazy imports: When True (default), heavy dependencies like litellm are
|
||||
# only imported when actually needed, reducing import time significantly.
|
||||
LAZY_IMPORTS = _str_to_bool(
|
||||
os.environ.get('PRAISONAI_LAZY_IMPORTS'),
|
||||
default=True
|
||||
)
|
||||
|
||||
# Telemetry: Opt-in by default (False). Users must explicitly enable.
|
||||
# Precedence (highest to lowest):
|
||||
# 1. DO_NOT_TRACK=true -> disabled
|
||||
@@ -99,3 +107,7 @@ def _get_plugins_list() -> list:
|
||||
return []
|
||||
# Parse comma-separated list
|
||||
return [p.strip() for p in env_value.split(',') if p.strip()]
|
||||
|
||||
|
||||
PLUGINS_ENABLED = _get_plugins_enabled()
|
||||
PLUGINS_LIST = _get_plugins_list()
|
||||
@@ -37,22 +37,6 @@ _cache_lock = threading.RLock()
|
||||
# Global module cache
|
||||
_module_cache: Dict[str, Any] = {}
|
||||
|
||||
# Import roots of praisonaiagents' declared *core* runtime dependencies (see
|
||||
# pyproject.toml ``[project].dependencies``). A lazy import that fails because
|
||||
# one of these is missing/broken indicates a genuinely broken install, which
|
||||
# must be surfaced rather than masked as a graceful ``None``. Optional
|
||||
# integrations whose own optional SDKs are absent are NOT in this set, so they
|
||||
# keep the documented ``None`` fallback.
|
||||
_CORE_REQUIRED_DEPENDENCIES = frozenset({
|
||||
"pydantic",
|
||||
"rich",
|
||||
"openai",
|
||||
"posthog",
|
||||
"aiohttp",
|
||||
"yaml", # PyYAML — core dep; import root differs from the package name
|
||||
"croniter",
|
||||
})
|
||||
|
||||
|
||||
def lazy_import(
|
||||
module_path: str,
|
||||
@@ -236,25 +220,9 @@ def create_lazy_getattr_with_fallback(
|
||||
_cache[name] = None
|
||||
return None
|
||||
except ImportError as exc:
|
||||
# Distinguish "optional integration not installed" from
|
||||
# "a required *core* dependency is broken". A missing/broken core
|
||||
# dependency (e.g. ``pydantic``) must be surfaced loudly instead
|
||||
# of being masked as ``None`` (which resurfaces later as a
|
||||
# confusing ``NoneType`` error). But an optional integration whose
|
||||
# own optional SDK is absent must keep the documented graceful
|
||||
# ``None`` fallback — the failing module name there belongs to
|
||||
# that optional SDK, not to us. We therefore only escalate when
|
||||
# the failing module is one of praisonaiagents' declared *core*
|
||||
# runtime dependencies; everything else stays a soft None.
|
||||
failing = getattr(exc, "name", None)
|
||||
failing_root = failing.split(".")[0] if failing else None
|
||||
if failing_root in _CORE_REQUIRED_DEPENDENCIES:
|
||||
raise ImportError(
|
||||
f"Failed to load {name!r} because importing {module_path!r} "
|
||||
f"raised: {exc}. This usually means a required core dependency "
|
||||
f"({failing_root!r}) is missing or broken, not that {name!r} is "
|
||||
f"an optional feature you chose not to install."
|
||||
) from exc
|
||||
# Optional module not available: preserve the graceful None
|
||||
# fallback, but record why so real import failures remain
|
||||
# diagnosable instead of surfacing later as NoneType errors.
|
||||
_logger.debug(
|
||||
"Lazy import of %r (%s.%s) failed: %s",
|
||||
name, module_path, attr_name, exc,
|
||||
|
||||
@@ -28,10 +28,8 @@ def _configure_environment():
|
||||
"PYDANTIC_WARNINGS_ENABLED": "False",
|
||||
}
|
||||
|
||||
# Use setdefault so a value the host exported before launch is preserved
|
||||
# (and not leaked/overwritten into subprocesses spawned afterwards).
|
||||
for key, value in env_vars.items():
|
||||
os.environ.setdefault(key, value)
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
# ========================================================================
|
||||
@@ -50,11 +48,7 @@ def _get_all_noisy_loggers() -> List[str]:
|
||||
# Markdown
|
||||
"markdown_it", "rich.markdown",
|
||||
# System
|
||||
# NOTE: "asyncio" is intentionally NOT suppressed here — it is the host
|
||||
# application's own logger, not a PraisonAI dependency's. Forcing it to
|
||||
# CRITICAL hides unrelated host errors (e.g. "Task exception was never
|
||||
# retrieved").
|
||||
"selector_events", "pydantic",
|
||||
"asyncio", "selector_events", "pydantic",
|
||||
"praisonaiagents.telemetry.telemetry",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -12,38 +12,6 @@ from contextlib import contextmanager, asynccontextmanager
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
|
||||
def run_async_in_sync_context(coro):
|
||||
"""
|
||||
Run an async coroutine to completion from a sync context with proper
|
||||
event loop handling.
|
||||
|
||||
Handles the common cases:
|
||||
1. No event loop exists - use asyncio.run()
|
||||
2. Event loop already running - avoid deadlock by running the coroutine
|
||||
to completion in a dedicated thread with its own event loop.
|
||||
|
||||
This is a live production helper on the sync tool-calling path (see
|
||||
``tool_execution.py``): it bridges ``async def`` tool bodies into the sync
|
||||
tool loop, otherwise a bare un-awaited coroutine would be handed to the
|
||||
model as the tool result and the tool body would never run (silent data
|
||||
loss).
|
||||
|
||||
Delegates to the single canonical bridge
|
||||
(:func:`utils.async_bridge.run_coroutine_from_any_context`) so all sync→async
|
||||
call sites share one implementation: the caller's ``contextvars`` (trace /
|
||||
session / approval context) are carried into the coroutine, and the running
|
||||
loop is handled on a dedicated worker thread rather than raising.
|
||||
|
||||
``timeout=None`` is passed deliberately: this helper backs the sync
|
||||
tool-calling path, where a tool's own timeout policy (or lack of one) already
|
||||
governs how long a tool may run. Imposing the bridge's default 5-minute
|
||||
deadline here would silently cancel long-running tools that previously had no
|
||||
limit — a backward-incompatible regression — so the bridge adds none.
|
||||
"""
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
return run_coroutine_from_any_context(coro, timeout=None)
|
||||
|
||||
|
||||
class DualLock:
|
||||
"""
|
||||
A dual-lock abstraction that automatically selects threading.Lock or asyncio.Lock
|
||||
@@ -69,24 +37,9 @@ class DualLock:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with a single underlying mutex shared by both call styles.
|
||||
|
||||
A single ``threading.Lock`` backs both ``sync()`` and ``async_lock()``
|
||||
so that holding one genuinely blocks the other — mutual exclusion is
|
||||
real across sync and async callers of the same instance. A plain
|
||||
``Lock`` (not ``RLock``) is used because the async path acquires on a
|
||||
worker thread but releases on the event-loop thread, which ``RLock``'s
|
||||
thread-ownership semantics forbid. Same-thread ``sync()`` reentrancy is
|
||||
preserved explicitly via an owner/depth guard below. The per-event-loop
|
||||
``asyncio.Lock`` only serialises coroutines on the same loop so they
|
||||
queue for the underlying mutex cooperatively instead of every coroutine
|
||||
racing straight into the blocking acquire.
|
||||
"""
|
||||
self._thread_lock = threading.Lock() # Shared mutex (cross-thread release ok)
|
||||
self._owner_lock = threading.Lock() # Guards the reentrancy bookkeeping
|
||||
self._owner_thread: Any = None # Thread id currently holding for sync reentrancy
|
||||
self._owner_depth = 0
|
||||
self._async_locks = WeakKeyDictionary() # Per-event-loop async gate
|
||||
"""Initialize with separate threading and asyncio locks."""
|
||||
self._thread_lock = threading.RLock() # Re-entrant lock to handle nested acquisitions
|
||||
self._async_locks = WeakKeyDictionary() # Per-event-loop async locks
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
"""Return an unlocked primitive with no event-loop state sharing."""
|
||||
@@ -96,31 +49,9 @@ class DualLock:
|
||||
|
||||
@contextmanager
|
||||
def sync(self):
|
||||
"""Acquire lock in synchronous context using the shared mutex.
|
||||
|
||||
Re-entrant for the same thread: a nested ``sync()`` on the thread that
|
||||
already holds the mutex does not re-acquire (and cannot self-deadlock).
|
||||
"""
|
||||
me = threading.get_ident()
|
||||
with self._owner_lock:
|
||||
reentrant = self._owner_thread == me and self._owner_depth > 0
|
||||
if reentrant:
|
||||
self._owner_depth += 1
|
||||
if not reentrant:
|
||||
self._thread_lock.acquire()
|
||||
with self._owner_lock:
|
||||
self._owner_thread = me
|
||||
self._owner_depth = 1
|
||||
try:
|
||||
"""Acquire lock in synchronous context using threading.Lock."""
|
||||
with self._thread_lock:
|
||||
yield
|
||||
finally:
|
||||
with self._owner_lock:
|
||||
self._owner_depth -= 1
|
||||
released = self._owner_depth == 0
|
||||
if released:
|
||||
self._owner_thread = None
|
||||
if released:
|
||||
self._thread_lock.release()
|
||||
|
||||
def _get_async_lock(self):
|
||||
"""Get or create an asyncio.Lock for the current event loop."""
|
||||
@@ -133,64 +64,16 @@ class DualLock:
|
||||
self._async_locks[loop] = asyncio.Lock()
|
||||
return self._async_locks[loop]
|
||||
|
||||
async def _acquire_shared_mutex(self, loop):
|
||||
"""Acquire the shared mutex off-loop, releasing it if we are cancelled.
|
||||
|
||||
``run_in_executor`` can complete (mutex acquired) after the awaiting
|
||||
task is cancelled. If we let the ``CancelledError`` propagate without
|
||||
checking, the worker would strand the mutex forever. We therefore wait
|
||||
on the acquisition, and on cancellation ensure the mutex is released
|
||||
once the worker finishes so no caller waits indefinitely.
|
||||
"""
|
||||
future = loop.run_in_executor(None, self._thread_lock.acquire)
|
||||
try:
|
||||
await asyncio.shield(future)
|
||||
except asyncio.CancelledError:
|
||||
# The worker may still be mid-acquire; wait for it to settle, then
|
||||
# release if it managed to take the mutex so it is never orphaned.
|
||||
try:
|
||||
acquired = await future
|
||||
except asyncio.CancelledError:
|
||||
acquired = False
|
||||
if acquired:
|
||||
self._thread_lock.release()
|
||||
raise
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_lock(self):
|
||||
"""Acquire lock in asynchronous context using the shared mutex.
|
||||
|
||||
Backs onto the *same* ``threading.Lock`` as ``sync()`` so a concurrent
|
||||
sync caller is genuinely excluded (and vice-versa). The blocking
|
||||
``acquire`` is offloaded to a worker thread so the event loop is never
|
||||
blocked while waiting; the matching ``release`` (allowed cross-thread on
|
||||
a plain ``Lock``) happens on the loop thread. A per-event-loop
|
||||
``asyncio.Lock`` serialises coroutines on this loop first so they don't
|
||||
all spin up executor hops at once.
|
||||
|
||||
While the mutex is held, the *event-loop thread* is registered as the
|
||||
sync owner so a nested ``with lock.sync()`` on that thread (used by the
|
||||
async handoff seeding path) is treated as re-entrant instead of
|
||||
self-deadlocking against the mutex we already hold.
|
||||
"""Acquire lock in asynchronous context using asyncio.Lock.
|
||||
|
||||
Uses a per-event-loop asyncio.Lock to ensure proper async coordination
|
||||
without blocking the event loop or violating thread ownership semantics.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
async_gate = self._get_async_lock()
|
||||
async with async_gate:
|
||||
await self._acquire_shared_mutex(loop)
|
||||
me = threading.get_ident()
|
||||
with self._owner_lock:
|
||||
self._owner_thread = me
|
||||
self._owner_depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with self._owner_lock:
|
||||
self._owner_depth -= 1
|
||||
released = self._owner_depth == 0
|
||||
if released:
|
||||
self._owner_thread = None
|
||||
if released:
|
||||
self._thread_lock.release()
|
||||
async_lock = self._get_async_lock()
|
||||
async with async_lock:
|
||||
yield
|
||||
|
||||
def is_async_context(self) -> bool:
|
||||
"""Check if we're currently in an async context."""
|
||||
@@ -264,11 +147,6 @@ class AsyncSafeState:
|
||||
@asynccontextmanager
|
||||
async def async_lock(self):
|
||||
"""Acquire lock in async context."""
|
||||
# Fail fast BEFORE blocking on the shared mutex: an in-progress
|
||||
# synchronous deepcopy holds the mutex for the whole copy, so waiting on
|
||||
# it here would block until the copy finished instead of surfacing the
|
||||
# documented RuntimeError (and could stall the loop indefinitely).
|
||||
self._raise_if_copying()
|
||||
async with self._lock.async_lock():
|
||||
self._begin_async_access()
|
||||
try:
|
||||
@@ -276,14 +154,6 @@ class AsyncSafeState:
|
||||
finally:
|
||||
self._end_async_access()
|
||||
|
||||
def _raise_if_copying(self) -> None:
|
||||
"""Reject entry while a synchronous deepcopy of this state is active."""
|
||||
with self._activity_lock:
|
||||
if self._copying:
|
||||
raise RuntimeError(
|
||||
"Cannot access AsyncSafeState during synchronous deepcopy"
|
||||
)
|
||||
|
||||
def _begin_async_access(self) -> None:
|
||||
"""Register async access unless a synchronous copy is in progress."""
|
||||
with self._activity_lock:
|
||||
@@ -300,32 +170,20 @@ class AsyncSafeState:
|
||||
|
||||
def __enter__(self):
|
||||
"""Support for synchronous context manager protocol (backward compatibility)."""
|
||||
self._sync_guard = self._lock.sync()
|
||||
self._sync_guard.__enter__()
|
||||
self._lock._thread_lock.acquire()
|
||||
return self.value
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Support for synchronous context manager protocol (backward compatibility)."""
|
||||
return self._sync_guard.__exit__(exc_type, exc_val, exc_tb)
|
||||
self._lock._thread_lock.release()
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Support for asynchronous context manager protocol."""
|
||||
# Fail fast before blocking on the mutex an in-progress deepcopy holds.
|
||||
self._raise_if_copying()
|
||||
loop = asyncio.get_running_loop()
|
||||
async_lock = self._lock._get_async_lock()
|
||||
await async_lock.acquire()
|
||||
try:
|
||||
# Hold the SAME underlying mutex used by the sync path so async and
|
||||
# sync accessors are mutually exclusive (matches DualLock.async_lock).
|
||||
# Cancellation-safe acquire so the mutex is never stranded if the
|
||||
# awaiting task is cancelled mid-acquisition.
|
||||
await self._lock._acquire_shared_mutex(loop)
|
||||
try:
|
||||
self._begin_async_access()
|
||||
except Exception:
|
||||
self._lock._thread_lock.release()
|
||||
raise
|
||||
self._begin_async_access()
|
||||
except Exception:
|
||||
async_lock.release()
|
||||
raise
|
||||
@@ -337,10 +195,7 @@ class AsyncSafeState:
|
||||
try:
|
||||
self._end_async_access()
|
||||
finally:
|
||||
try:
|
||||
self._lock._thread_lock.release()
|
||||
finally:
|
||||
async_lock.release()
|
||||
async_lock.release()
|
||||
return None
|
||||
|
||||
def get(self) -> Any:
|
||||
|
||||
@@ -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
@@ -31,66 +31,17 @@ from praisonaiagents._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _DynamicLimiter:
|
||||
"""Resizable counting limiter: capacity can change with holders outstanding.
|
||||
|
||||
Unlike swapping a fresh threading.Semaphore on every retune (which lets
|
||||
holders of the old primitive and the release() path operate on different
|
||||
objects), this keeps one long-lived object per agent name and mutates its
|
||||
capacity in place. Every acquirer and every release() therefore always
|
||||
operate on the same shared state, so a retune takes effect immediately and
|
||||
the configured cap can never be silently exceeded. Loop-neutral: uses a
|
||||
threading.Condition so it is a true global cap across event loops/threads.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int):
|
||||
self._limit = limit
|
||||
self._held = 0
|
||||
self._cond = threading.Condition()
|
||||
|
||||
def set_limit(self, limit: int) -> None:
|
||||
with self._cond:
|
||||
self._limit = limit
|
||||
self._cond.notify_all()
|
||||
|
||||
def acquire(self, blocking: bool = True) -> bool:
|
||||
with self._cond:
|
||||
while self._limit > 0 and self._held >= self._limit:
|
||||
if not blocking:
|
||||
return False
|
||||
self._cond.wait(timeout=0.05)
|
||||
self._held += 1
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
with self._cond:
|
||||
self._held = max(0, self._held - 1)
|
||||
self._cond.notify_all()
|
||||
|
||||
def has_holders(self) -> bool:
|
||||
with self._cond:
|
||||
return self._held > 0
|
||||
|
||||
|
||||
class ConcurrencyRegistry:
|
||||
"""Registry for per-agent concurrency limits.
|
||||
|
||||
Thread-safe. Each agent name maps to a loop-neutral, resizable limiter, so
|
||||
the limit is a true global cap across every event loop and thread and a
|
||||
retune takes effect immediately for in-flight holders.
|
||||
|
||||
Thread-safe. Each agent name maps to an asyncio.Semaphore.
|
||||
Limit of 0 means unlimited (no throttling).
|
||||
"""
|
||||
|
||||
def __init__(self, default_limit: int = 0):
|
||||
self._default_limit = default_limit
|
||||
self._limits: Dict[str, int] = {}
|
||||
# One long-lived, loop-neutral _DynamicLimiter per agent. Its capacity is
|
||||
# mutated in place on retune (never swapped for a fresh object), so every
|
||||
# acquirer and every release() always share the same state and the
|
||||
# configured cap can never be silently exceeded. Loop-neutral means the
|
||||
# per-agent limit is a true global cap under multi-loop / multi-thread
|
||||
# deployments; async callers poll without blocking their loop.
|
||||
self._limiters: Dict[str, _DynamicLimiter] = {}
|
||||
self._semaphores: Dict[str, asyncio.Semaphore] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_limit(self, agent_name: str, max_concurrent: int) -> None:
|
||||
@@ -102,11 +53,8 @@ class ConcurrencyRegistry:
|
||||
"""
|
||||
with self._lock:
|
||||
self._limits[agent_name] = max_concurrent
|
||||
# Mutate the existing limiter in place so the retune takes effect
|
||||
# immediately for in-flight holders without ever swapping the object.
|
||||
limiter = self._limiters.get(agent_name)
|
||||
if limiter is not None:
|
||||
limiter.set_limit(max_concurrent)
|
||||
# Reset semaphore so next acquire creates a fresh one
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def get_limit(self, agent_name: str) -> int:
|
||||
"""Get concurrency limit for an agent."""
|
||||
@@ -114,78 +62,61 @@ class ConcurrencyRegistry:
|
||||
return self._limits.get(agent_name, self._default_limit)
|
||||
|
||||
def remove_limit(self, agent_name: str) -> None:
|
||||
"""Remove concurrency limit for an agent (reverts to default).
|
||||
|
||||
The limiter object is kept alive while any permit is still held, so
|
||||
in-flight acquire()/release() pairs keep operating on the same shared
|
||||
state; dropping it mid-flight would strand those permits and let a
|
||||
later set_limit() start from a fresh, empty count and admit work beyond
|
||||
the configured cap. Once no permits are outstanding it is safe to drop.
|
||||
"""
|
||||
"""Remove concurrency limit for an agent (reverts to default)."""
|
||||
with self._lock:
|
||||
self._limits.pop(agent_name, None)
|
||||
# Retune the surviving limiter to unlimited so it stops blocking
|
||||
# while remaining the single accounting object for its holders.
|
||||
limiter = self._limiters.get(agent_name)
|
||||
if limiter is None:
|
||||
return
|
||||
limiter.set_limit(0)
|
||||
if not limiter.has_holders():
|
||||
self._limiters.pop(agent_name, None)
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def _get_limiter(self, agent_name: str) -> Optional[_DynamicLimiter]:
|
||||
"""Get or create the loop-neutral limiter for an agent.
|
||||
|
||||
Returns None only when the agent has never been given a limit (pure
|
||||
unlimited fast path). Once a limiter exists it is always returned — even
|
||||
while unlimited (limit 0) — so acquire() and release() stay symmetric
|
||||
across a mid-flight retune to/from unlimited and the count can never be
|
||||
corrupted. The same _DynamicLimiter is shared across every event loop
|
||||
and thread, so a configured limit is a true global cap.
|
||||
"""
|
||||
def _get_semaphore(self, agent_name: str) -> Optional[asyncio.Semaphore]:
|
||||
"""Get or create semaphore for agent. Returns None if unlimited."""
|
||||
with self._lock:
|
||||
limit = self._limits.get(agent_name, self._default_limit)
|
||||
limiter = self._limiters.get(agent_name)
|
||||
if limiter is None:
|
||||
if limit <= 0:
|
||||
return None
|
||||
limiter = _DynamicLimiter(limit)
|
||||
self._limiters[agent_name] = limiter
|
||||
return limiter
|
||||
if limit <= 0:
|
||||
return None
|
||||
if agent_name not in self._semaphores:
|
||||
self._semaphores[agent_name] = asyncio.Semaphore(limit)
|
||||
return self._semaphores[agent_name]
|
||||
|
||||
async def acquire(self, agent_name: str) -> None:
|
||||
"""Acquire concurrency slot for agent. No-op if unlimited.
|
||||
|
||||
Waits on the loop-neutral limiter in short, cancellable polls so the
|
||||
running event loop is never blocked while other tasks hold permits, and
|
||||
a cancelled/timed-out await never leaves a thread blocked on acquire().
|
||||
"""
|
||||
limiter = self._get_limiter(agent_name)
|
||||
if limiter is None:
|
||||
return
|
||||
while True:
|
||||
if limiter.acquire(blocking=False):
|
||||
return
|
||||
# Yield to the loop; on cancellation this raises and no permit leaks.
|
||||
await asyncio.sleep(0.005)
|
||||
"""Acquire concurrency slot for agent. No-op if unlimited."""
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is not None:
|
||||
await sem.acquire()
|
||||
|
||||
def acquire_sync(self, agent_name: str) -> None:
|
||||
"""Synchronous acquire — for non-async code paths.
|
||||
|
||||
Prefer async acquire() when possible. Blocks the calling thread until a
|
||||
permit is available. Safe to call whether or not a loop is running in the
|
||||
current thread, since the limiter is loop-neutral.
|
||||
|
||||
Prefer async acquire() when possible.
|
||||
If called while an event loop is already running in the current thread,
|
||||
this method raises RuntimeError to avoid deadlock.
|
||||
"""
|
||||
limiter = self._get_limiter(agent_name)
|
||||
if limiter is not None:
|
||||
limiter.acquire()
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is None:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop — safe to create one
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(sem.acquire())
|
||||
finally:
|
||||
loop.close()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"acquire_sync('{agent_name}') cannot be called with a running event loop; "
|
||||
"use async acquire() in async contexts."
|
||||
)
|
||||
|
||||
def release(self, agent_name: str) -> None:
|
||||
"""Release concurrency slot for agent. No-op if unlimited."""
|
||||
with self._lock:
|
||||
limiter = self._limiters.get(agent_name)
|
||||
if limiter is not None:
|
||||
limiter.release()
|
||||
sem = self._semaphores.get(agent_name)
|
||||
if sem is not None:
|
||||
try:
|
||||
sem.release()
|
||||
except ValueError:
|
||||
pass # Already fully released
|
||||
|
||||
@asynccontextmanager
|
||||
async def throttle(self, agent_name: str):
|
||||
|
||||
@@ -58,8 +58,8 @@ def save_cost_report(
|
||||
}
|
||||
|
||||
filepath = COST_DIR / f"{safe_name}.json"
|
||||
from ..utils.atomic_io import atomic_write_json
|
||||
atomic_write_json(str(filepath), report, indent=2)
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
logger.info(f"[costs] Saved cost report to {filepath}")
|
||||
return str(filepath)
|
||||
|
||||
@@ -898,18 +898,14 @@ You are a professional research analyst. When conducting research:
|
||||
if grounding_file_prompt:
|
||||
create_params["grounding_file_prompt"] = grounding_file_prompt
|
||||
|
||||
interaction = await asyncio.to_thread(
|
||||
self.gemini_client.interactions.create, **create_params
|
||||
)
|
||||
interaction = self.gemini_client.interactions.create(**create_params)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Gemini research started (async): {interaction.id}")
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
interaction = await asyncio.to_thread(
|
||||
self.gemini_client.interactions.get, interaction.id
|
||||
)
|
||||
interaction = self.gemini_client.interactions.get(interaction.id)
|
||||
|
||||
if interaction.status == "completed":
|
||||
if self.verbose:
|
||||
|
||||
@@ -22,28 +22,49 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
# Shared HTTP launch state — the process-wide registry is defined in
|
||||
# agent/server_registry.py and shared with PraisonAIAgents.launch() so a
|
||||
# standalone Agent and a team launched on the same port cannot double-bind it or
|
||||
# hide each other's endpoints.
|
||||
from .server_registry import _server_registry
|
||||
# Shared HTTP launch state (Agent.launch() multi-endpoint registration)
|
||||
_server_lock = threading.Lock()
|
||||
_server_started: Dict[int, bool] = {}
|
||||
_registered_agents: Dict[int, Dict[str, str]] = {}
|
||||
_shared_apps: Dict[int, Any] = {}
|
||||
|
||||
|
||||
def cleanup_launch_registration(agent_id: str) -> None:
|
||||
"""Remove launch() endpoints registered for ``agent_id`` from the shared
|
||||
HTTP state that ``Agent.launch()`` actually populates.
|
||||
|
||||
``Agent.launch()`` reserves routes on the shared ``_server_registry``. This
|
||||
tears those routes back down so a closed agent's endpoint stops accepting
|
||||
requests and the request handler closure no longer keeps the Agent object
|
||||
graph alive.
|
||||
|
||||
The endpoint-registry removal and the matching FastAPI route teardown are
|
||||
performed together under the registry lock (see
|
||||
``_AgentServerRegistry.teardown_routes_for``) so cleanup cannot race a
|
||||
concurrent launch that reserves and registers a route on the same port.
|
||||
``Agent.launch()`` writes routes into the module-level ``_registered_agents``
|
||||
and ``_shared_apps`` dicts above. This tears those routes back down so a
|
||||
closed agent's endpoint stops accepting requests and the request handler
|
||||
closure no longer keeps the Agent object graph alive.
|
||||
"""
|
||||
_server_registry.teardown_routes_for(agent_id)
|
||||
with _server_lock:
|
||||
for port, paths in list(_registered_agents.items()):
|
||||
stale_paths = [p for p, aid in paths.items() if aid == agent_id]
|
||||
for p in stale_paths:
|
||||
del paths[p]
|
||||
app = _shared_apps.get(port)
|
||||
if app is not None:
|
||||
try:
|
||||
# Only drop the agent-owned POST route. launch() always
|
||||
# registers the handler via ``.post(path)``, so filtering
|
||||
# on both path AND method preserves unrelated routes that
|
||||
# share the path with a different verb (e.g. the built-in
|
||||
# GET /health and GET /).
|
||||
app.router.routes = [
|
||||
r for r in app.router.routes
|
||||
if not (
|
||||
getattr(r, "path", None) == p
|
||||
and "POST" in (getattr(r, "methods", None) or set())
|
||||
)
|
||||
]
|
||||
# Invalidate cached OpenAPI schema so removed routes
|
||||
# disappear from /openapi.json and /docs.
|
||||
app.openapi_schema = None
|
||||
except Exception:
|
||||
pass
|
||||
if not paths:
|
||||
_registered_agents.pop(port, None)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -200,12 +221,8 @@ class ExecutionMixin:
|
||||
response = await self.achat(prompt, **kwargs)
|
||||
response_str = str(response) if response else ""
|
||||
|
||||
# Auto-save session after chat. Offloaded to a worker thread so
|
||||
# the synchronous FileLock/JSON read-modify-write never stalls
|
||||
# the shared event loop (the session store is a process-wide
|
||||
# singleton, so a blocking write here blocks every concurrent
|
||||
# async agent task on the same loop).
|
||||
await asyncio.to_thread(self._auto_save_session)
|
||||
# Auto-save session after chat
|
||||
self._auto_save_session()
|
||||
|
||||
# Wrap in AutonomyResult for consistent API
|
||||
from .autonomy import AutonomyResult
|
||||
@@ -280,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.
|
||||
@@ -392,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).
|
||||
@@ -476,8 +467,7 @@ class ExecutionMixin:
|
||||
extra={"session_id": sid, "managed_session_id": msid},
|
||||
exc_info=True,
|
||||
)
|
||||
# Offload the blocking session write off the event loop.
|
||||
await asyncio.to_thread(self._auto_save_session)
|
||||
self._auto_save_session()
|
||||
|
||||
return result
|
||||
|
||||
@@ -502,7 +492,7 @@ class ExecutionMixin:
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_async)
|
||||
return future.result()
|
||||
|
||||
|
||||
except RuntimeError:
|
||||
# No event loop running, safe to use asyncio.run()
|
||||
return asyncio.run(self.backend.execute(prompt, **kwargs))
|
||||
@@ -786,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.
|
||||
@@ -1405,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 = (
|
||||
@@ -1561,46 +1523,14 @@ Write the complete compiled report:"""
|
||||
),
|
||||
)
|
||||
|
||||
# Bound the worker thread's wait on the async tool by the same per-agent
|
||||
# tool timeout the direct path applies (ToolConfig.timeout, seconds). The
|
||||
# inner coroutine already wraps itself in asyncio.wait_for, but the worker
|
||||
# blocking on future.result() has no bound of its own: if the inner path
|
||||
# hangs (or no timeout is configured) the middleware worker would block a
|
||||
# thread-pool worker indefinitely. Give it a slightly larger grace bound so
|
||||
# the inner wait_for surfaces its structured timeout first when configured.
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
worker_timeout = (tool_timeout + 1.0) if tool_timeout and tool_timeout > 0 else None
|
||||
|
||||
def _final_handler(req):
|
||||
import concurrent.futures
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._execute_tool_async_with_retry(
|
||||
req.tool_name, req.arguments, tool_call_id, tools_override
|
||||
),
|
||||
loop,
|
||||
)
|
||||
try:
|
||||
result = future.result(timeout=worker_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
# The scheduled coroutine did not finish within the bound. Request
|
||||
# cancellation on the running loop so it stops rather than leaking,
|
||||
# and surface the same structured timeout error the direct async
|
||||
# path returns (retryable=False: an uncancellable side effect must
|
||||
# not be re-run).
|
||||
future.cancel()
|
||||
logger.warning(
|
||||
"Middleware async tool '%s' timed out after %ss (agent=%s, run_id=%s, tool_call_id=%s)",
|
||||
req.tool_name,
|
||||
tool_timeout,
|
||||
self.name,
|
||||
getattr(self, '_current_run_id', 'unknown'),
|
||||
tool_call_id,
|
||||
)
|
||||
result = {
|
||||
"error": f"Tool timed out after {tool_timeout}s",
|
||||
"timeout": True,
|
||||
"_praison_retryable": False,
|
||||
}
|
||||
result = future.result()
|
||||
return ToolResponse(tool_name=req.tool_name, result=result)
|
||||
|
||||
def _run_chain():
|
||||
@@ -1638,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)
|
||||
|
||||
@@ -1738,51 +1657,14 @@ Write the complete compiled report:"""
|
||||
|
||||
# Policy/guardrail gate (protocol-driven). Mirrors the sync path in
|
||||
# _execute_tool_impl so async callers cannot bypass a PolicyEngine
|
||||
# deny or a tool-call guardrail. A user-supplied guardrail/policy may
|
||||
# do blocking work (an LLM moderation call, a DB/network lookup), so
|
||||
# offload to a worker thread rather than run it inline and stall the
|
||||
# event loop for every other coroutine sharing it.
|
||||
# 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 = await asyncio.to_thread(
|
||||
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.
|
||||
# Offloaded to a thread: a user guardrail may block on I/O.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
mcp_result = await asyncio.to_thread(
|
||||
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
|
||||
@@ -1790,10 +1672,7 @@ Write the complete compiled report:"""
|
||||
func = None
|
||||
tools_to_search = tools_override if tools_override is not None else self.tools
|
||||
from ..tools.base import BaseTool
|
||||
plugin_owners = getattr(self, "_plugin_tool_owners", None)
|
||||
for tool in tools_to_search:
|
||||
if plugin_owners and not self._is_plugin_tool_active(tool):
|
||||
continue
|
||||
if isinstance(tool, BaseTool) and getattr(tool, 'name', None) == function_name:
|
||||
func = tool
|
||||
break
|
||||
@@ -1804,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
|
||||
@@ -1888,21 +1727,6 @@ Write the complete compiled report:"""
|
||||
|
||||
from ..streaming.events import tool_progress_channel
|
||||
|
||||
# breaker.acall() records the invocation outcome itself. Both record
|
||||
# sites below then recorded the same failure a SECOND time -- once
|
||||
# off the returned error dict, once in the raised-exception handler
|
||||
# -- so every async tool failure counted twice and the breaker
|
||||
# opened after ceil(threshold/2) calls (3 instead of the configured
|
||||
# 5), out of parity with the sync path. This flag marks that the
|
||||
# breaker already owns the outcome; it stays False when
|
||||
# asyncio.wait_for times out, since the breaker never got a result.
|
||||
#
|
||||
# Declared outside the try: the except handler below reads it, and
|
||||
# roughly seventy lines run between the try and the breaker setup.
|
||||
# An exception in that window would otherwise raise NameError here
|
||||
# and mask the original failure.
|
||||
breaker_saw_outcome = {"done": False}
|
||||
|
||||
try:
|
||||
# BaseTool instances (plugin system, e.g. BrowserBaseTool) are not
|
||||
# directly callable — dispatch to their .run() method like the sync path.
|
||||
@@ -1927,134 +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()
|
||||
# Set before the await: acall records an outcome however it
|
||||
# exits (returning, raising _ToolFailure, or letting a raw
|
||||
# tool exception through). The only exit that records
|
||||
# nothing is _CircuitBreakerException, and that path returns
|
||||
# _circuit_open_result() without reaching either record site.
|
||||
breaker_saw_outcome["done"] = True
|
||||
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
|
||||
@@ -2063,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
|
||||
@@ -2077,78 +1786,13 @@ Write the complete compiled report:"""
|
||||
# Fall through (not return) so the loop guard records this
|
||||
# timeout as a failure — repeated timeouts must accumulate
|
||||
# toward the BLOCK/HALT thresholds like any other failure.
|
||||
#
|
||||
# A timeout cancels the awaited ``_invoke_guarded``; the
|
||||
# cancellation surfaces inside ``breaker.acall`` as
|
||||
# ``asyncio.CancelledError`` (a BaseException, not
|
||||
# Exception), so ``acall`` never ran ``_on_failure`` for
|
||||
# it. The fall-through breaker-record block below only
|
||||
# runs when ``breaker is None``, so without recording it
|
||||
# here a repeatedly timing-out tool would never open the
|
||||
# circuit. Record the one failure directly on the breaker
|
||||
# (the block below stays disabled, so it is counted once).
|
||||
if breaker is not None:
|
||||
try:
|
||||
breaker._on_failure()
|
||||
except Exception:
|
||||
logging.debug(
|
||||
"Failed to record tool timeout on circuit breaker",
|
||||
exc_info=True,
|
||||
)
|
||||
result = {
|
||||
"error": f"Tool timed out after {tool_timeout}s",
|
||||
"timeout": True,
|
||||
"_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.
|
||||
#
|
||||
# Only when the breaker wrapper did not already run. When
|
||||
# ``breaker`` is set, ``breaker.acall`` above has already called
|
||||
# _on_success/_on_failure for this invocation, and recording
|
||||
# again counted every outcome twice -- halving the configured
|
||||
# failure_threshold (a breaker set to 5 opened after 3) and, on
|
||||
# the success side, closing a HALF_OPEN circuit in half the
|
||||
# required successes.
|
||||
breaker_record = (
|
||||
getattr(self, '_circuit_breaker_record', None)
|
||||
if breaker is None else 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.
|
||||
# Offloaded to a thread: a user guardrail may block on I/O (an LLM
|
||||
# moderation call, a network lookup), which would otherwise stall
|
||||
# the event loop for every other coroutine sharing it.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
result = await asyncio.to_thread(
|
||||
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
|
||||
@@ -2179,22 +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. Approval/permission/
|
||||
# policy/guardrail denials surface as error dicts (handled
|
||||
# above), not raises, so this path only ever sees genuine tool
|
||||
# failures.
|
||||
#
|
||||
# Again only when the breaker wrapper did not run: ``acall``
|
||||
# records the failure and re-raises, so the exception arriving
|
||||
# here has already been counted once.
|
||||
breaker_record = (
|
||||
getattr(self, '_circuit_breaker_record', None)
|
||||
if breaker is None else 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
|
||||
@@ -2271,13 +1899,18 @@ Write the complete compiled report:"""
|
||||
NOTE: This implementation will be moved to wrapper layer in future version.
|
||||
For now, it maintains backward compatibility while following lazy import patterns.
|
||||
"""
|
||||
global _server_started, _registered_agents, _shared_apps, _server_lock
|
||||
|
||||
from .launch_security import authorise_launch_request, resolve_launch_host
|
||||
|
||||
# Try to import FastAPI dependencies - lazy loading
|
||||
try:
|
||||
from fastapi import HTTPException, Request
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import threading
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
# Define the request model here since we need pydantic
|
||||
@@ -2303,102 +1936,130 @@ Write the complete compiled report:"""
|
||||
# process-wide launch token and retroactively 401 a running endpoint.
|
||||
host = resolve_launch_host(host)
|
||||
|
||||
# Get or create the shared FastAPI app for this port (shared with
|
||||
# PraisonAIAgents.launch via the same _server_registry singleton).
|
||||
app, is_new_app = _server_registry.get_or_create_app(
|
||||
port,
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
)
|
||||
should_start = False
|
||||
with _server_lock:
|
||||
# Initialize port-specific collections if needed (once per port)
|
||||
if port not in _registered_agents:
|
||||
_registered_agents[port] = {}
|
||||
|
||||
if is_new_app:
|
||||
# Add a root endpoint with a welcome message
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
|
||||
"endpoints": _server_registry.list_routes(port)
|
||||
}
|
||||
|
||||
# Add healthcheck endpoint
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {
|
||||
"status": "ok",
|
||||
"endpoints": _server_registry.list_routes(port)
|
||||
}
|
||||
|
||||
# Normalize path to ensure it starts with /
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
|
||||
# Atomically reserve the route; on collision the registry returns a
|
||||
# de-duplicated path so multiple agents can share a single port.
|
||||
path, original_path = _server_registry.reserve_route(port, path, self.agent_id)
|
||||
if original_path is not None:
|
||||
logging.warning(f"Path '{original_path}' is already registered on port {port}.")
|
||||
print(f"⚠️ Warning: Path '{original_path}' is already registered on port {port}.")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Define the endpoint handler
|
||||
@app.post(path)
|
||||
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
request_data = await request.json()
|
||||
if "query" not in request_data:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
query = request_data["query"]
|
||||
except Exception:
|
||||
# Fallback to form data or query params
|
||||
form_data = await request.form()
|
||||
if "query" in form_data:
|
||||
query = form_data["query"]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
else:
|
||||
query = query_data.query
|
||||
|
||||
try:
|
||||
# Use async version if available, otherwise use sync version
|
||||
if asyncio.iscoroutinefunction(self.chat):
|
||||
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
|
||||
else:
|
||||
# Run sync function in a thread to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
|
||||
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing query: {str(e)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Error processing query: {str(e)}"}
|
||||
# Initialize shared FastAPI app if not already created for this port
|
||||
if _shared_apps.get(port) is None:
|
||||
_shared_apps[port] = FastAPI(
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
description="API for interacting with PraisonAI Agents"
|
||||
)
|
||||
|
||||
# Invalidate the cached OpenAPI schema so routes registered by later
|
||||
# shared-port launch() calls still show up in /openapi.json and /docs.
|
||||
# FastAPI caches app.openapi_schema on first access and does not
|
||||
# regenerate it when new routes are added afterwards.
|
||||
app.openapi_schema = None
|
||||
# Add a root endpoint with a welcome message
|
||||
@_shared_apps[port].get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
|
||||
"endpoints": list(_registered_agents[port].keys())
|
||||
}
|
||||
|
||||
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
|
||||
# Add healthcheck endpoint
|
||||
@_shared_apps[port].get("/health")
|
||||
async def healthcheck():
|
||||
return {
|
||||
"status": "ok",
|
||||
"endpoints": list(_registered_agents[port].keys())
|
||||
}
|
||||
|
||||
# Start the shared server for this port if it is not already running.
|
||||
# start_server_if_needed atomically checks-and-marks the port and blocks
|
||||
# on the registry's readiness Event (configurable via
|
||||
# PRAISONAI_SERVER_READY_TIMEOUT) instead of a heuristic sleep, matching
|
||||
# PraisonAIAgents.launch().
|
||||
started = _server_registry.start_server_if_needed(
|
||||
port, host, log_level="debug" if debug else "info"
|
||||
)
|
||||
endpoints = _server_registry.list_routes(port)
|
||||
if started:
|
||||
print(f"✅ FastAPI server started at http://{host}:{port}")
|
||||
print(f"📚 API documentation available at http://{host}:{port}/docs")
|
||||
print(f"🔌 Available endpoints on port {port}: {', '.join(endpoints)}")
|
||||
# The path registration below must run on EVERY call, not just when the
|
||||
# port is new, so multiple agents can share a single port.
|
||||
|
||||
# Normalize path to ensure it starts with /
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
|
||||
# Check if path is already registered for this port
|
||||
if path in _registered_agents[port]:
|
||||
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
|
||||
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
|
||||
# Use a modified path to avoid conflicts
|
||||
original_path = path
|
||||
path = f"{path}_{self.agent_id[:6]}"
|
||||
logging.warning(f"Using '{path}' instead of '{original_path}'")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Register the agent to this path
|
||||
_registered_agents[port][path] = self.agent_id
|
||||
|
||||
# Define the endpoint handler
|
||||
@_shared_apps[port].post(path)
|
||||
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
request_data = await request.json()
|
||||
if "query" not in request_data:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
query = request_data["query"]
|
||||
except Exception:
|
||||
# Fallback to form data or query params
|
||||
form_data = await request.form()
|
||||
if "query" in form_data:
|
||||
query = form_data["query"]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
else:
|
||||
query = query_data.query
|
||||
|
||||
try:
|
||||
# Use async version if available, otherwise use sync version
|
||||
if asyncio.iscoroutinefunction(self.chat):
|
||||
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
|
||||
else:
|
||||
# Run sync function in a thread to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
|
||||
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing query: {str(e)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Error processing query: {str(e)}"}
|
||||
)
|
||||
|
||||
# Invalidate the cached OpenAPI schema so routes registered by later
|
||||
# shared-port launch() calls still show up in /openapi.json and /docs.
|
||||
# FastAPI caches app.openapi_schema on first access and does not
|
||||
# regenerate it when new routes are added afterwards.
|
||||
_shared_apps[port].openapi_schema = None
|
||||
|
||||
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
|
||||
|
||||
# Check and mark server as started atomically to prevent race conditions
|
||||
should_start = not _server_started.get(port, False)
|
||||
if should_start:
|
||||
_server_started[port] = True
|
||||
|
||||
# Server start/wait outside the lock to avoid holding it during sleep
|
||||
if should_start:
|
||||
# Start the server in a separate thread
|
||||
def run_server():
|
||||
try:
|
||||
print(f"✅ FastAPI server started at http://{host}:{port}")
|
||||
print(f"📚 API documentation available at http://{host}:{port}/docs")
|
||||
print(f"🔌 Available endpoints: {', '.join(list(_registered_agents[port].keys()))}")
|
||||
uvicorn.run(_shared_apps[port], host=host, port=port, log_level="debug" if debug else "info")
|
||||
except Exception as e:
|
||||
logging.error(f"Error starting server: {str(e)}", exc_info=True)
|
||||
print(f"❌ Error starting server: {str(e)}")
|
||||
|
||||
# Run server in a background thread
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Wait for a moment to allow the server to start and register endpoints
|
||||
self._safe_sleep(0.5)
|
||||
else:
|
||||
# If server is already running, wait a moment to make sure the endpoint is registered
|
||||
self._safe_sleep(0.1)
|
||||
print(f"🔌 Available endpoints on port {port}: {', '.join(list(_registered_agents[port].keys()))}")
|
||||
|
||||
# Get the stack frame to check if this is the last launch() call in the script
|
||||
import inspect
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import inspect
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
from contextlib import contextmanager
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import contextvars
|
||||
@@ -211,44 +211,6 @@ def _clear_handoff_chain() -> None:
|
||||
"""Clear the handoff chain."""
|
||||
_handoff_chain_var.set([])
|
||||
|
||||
|
||||
# Serializes lazy creation of each target agent's ``_handoff_seed_lock`` so two
|
||||
# threads that make the first concurrent handoff to the same target observe the
|
||||
# same lock object (double-checked locking) instead of each minting — and then
|
||||
# seeding under — its own, which would defeat the per-target serialization.
|
||||
_HANDOFF_SEED_LOCK_INIT_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _get_handoff_seed_lock(agent: 'Agent') -> 'DualLock':
|
||||
"""Return a per-target-agent lock serializing handoff history seeding.
|
||||
|
||||
Concurrent handoffs (e.g. ``parallel_handoffs``) can target the *same*
|
||||
agent object. ``_seed_target_history`` mutates that shared instance's
|
||||
``chat_history`` in place, so overlapping seeds would interleave and leak
|
||||
one caller's prior context into another's. Serializing per target agent
|
||||
keeps each seeded chat call atomic without changing the public API.
|
||||
|
||||
Creation is serialized through a module-level guard (double-checked locking)
|
||||
so racing first-handoffs to the same target can't each create a distinct
|
||||
lock and seed concurrently.
|
||||
"""
|
||||
from .async_safety import DualLock
|
||||
lock = getattr(agent, '_handoff_seed_lock', None)
|
||||
if lock is None:
|
||||
with _HANDOFF_SEED_LOCK_INIT_GUARD:
|
||||
lock = getattr(agent, '_handoff_seed_lock', None)
|
||||
if lock is None:
|
||||
lock = DualLock()
|
||||
try:
|
||||
agent._handoff_seed_lock = lock
|
||||
except Exception:
|
||||
# Target can't hold the attribute (e.g. __slots__). Return a
|
||||
# transient lock; correctness for that exotic case is no
|
||||
# worse than before, and the common path is fully guarded.
|
||||
pass
|
||||
return lock
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffInputData:
|
||||
"""Data passed to a handoff target agent."""
|
||||
@@ -508,31 +470,6 @@ class Handoff:
|
||||
return value
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
def _apply_seed(self, prior_messages: Optional[List[Any]]):
|
||||
"""Prepend ``prior_messages`` to the target's ``chat_history``.
|
||||
|
||||
Returns ``(seeded, original)`` where ``original`` is the list to restore
|
||||
on exit and ``seeded`` indicates a restore is needed. Callers MUST hold
|
||||
the per-agent seed lock around this + the chat call + ``_restore_seed``.
|
||||
"""
|
||||
existing = getattr(self.agent, 'chat_history', None)
|
||||
if not prior_messages or not isinstance(existing, list):
|
||||
return False, None
|
||||
try:
|
||||
prior = list(prior_messages)
|
||||
except TypeError:
|
||||
return False, None
|
||||
original = existing
|
||||
# Avoid re-seeding if the context is already at the head.
|
||||
if existing[:len(prior)] != prior:
|
||||
self.agent.chat_history = prior + existing
|
||||
return True, original
|
||||
|
||||
def _restore_seed(self, seeded: bool, original: Optional[List[Any]]) -> None:
|
||||
"""Restore the target's original ``chat_history`` after a seeded call."""
|
||||
if seeded:
|
||||
self.agent.chat_history = original
|
||||
|
||||
@contextmanager
|
||||
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
|
||||
"""Temporarily seed the filtered handoff context onto the target agent.
|
||||
@@ -546,45 +483,25 @@ class Handoff:
|
||||
restored on exit. This keeps handoff context from leaking into later
|
||||
handoffs or ordinary chats that reuse the same target agent, and stops the
|
||||
target prompt from growing across sequential handoffs.
|
||||
|
||||
Because ``chat_history`` is shared mutable state on the target agent,
|
||||
concurrent handoffs to the *same* target (e.g. via ``parallel_handoffs``)
|
||||
would otherwise interleave and corrupt each other's context. The seed +
|
||||
chat call are serialized per target agent via a ``DualLock`` so each
|
||||
seeded turn stays atomic. This is the synchronous variant.
|
||||
No shared mutable state is retained between agents.
|
||||
"""
|
||||
lock = _get_handoff_seed_lock(self.agent)
|
||||
with lock.sync():
|
||||
seeded, original = self._apply_seed(prior_messages)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._restore_seed(seeded, original)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _seed_target_history_async(self, prior_messages: Optional[List[Any]]):
|
||||
"""Async variant of :meth:`_seed_target_history`.
|
||||
|
||||
Uses the per-target-agent lock's async side so overlapping awaited
|
||||
handoffs to the same agent serialize on the event loop instead of
|
||||
blocking it, keeping each seeded ``achat`` turn atomic.
|
||||
|
||||
``DualLock`` backs ``sync()`` and ``async_lock()`` onto one shared mutex,
|
||||
so ``async_lock()`` alone already excludes a concurrent *synchronous*
|
||||
handoff on another thread. We still take the inner ``sync()`` so the
|
||||
seed/restore reads as one explicit critical section; because
|
||||
``async_lock()`` registers the event-loop thread as the mutex owner, that
|
||||
nested ``sync()`` is treated as re-entrant on the same thread instead of
|
||||
self-deadlocking against the mutex the async side already holds.
|
||||
"""
|
||||
lock = _get_handoff_seed_lock(self.agent)
|
||||
async with lock.async_lock():
|
||||
with lock.sync():
|
||||
seeded, original = self._apply_seed(prior_messages)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._restore_seed(seeded, original)
|
||||
existing = getattr(self.agent, 'chat_history', None)
|
||||
if not prior_messages or not isinstance(existing, list):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
prior = list(prior_messages)
|
||||
except TypeError:
|
||||
yield
|
||||
return
|
||||
original = existing
|
||||
try:
|
||||
# Avoid re-seeding if the context is already at the head.
|
||||
if existing[:len(prior)] != prior:
|
||||
self.agent.chat_history = prior + existing
|
||||
yield
|
||||
finally:
|
||||
self.agent.chat_history = original
|
||||
|
||||
def _execute_with_runtime_resolution(
|
||||
self,
|
||||
@@ -621,7 +538,7 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
async with self._seed_target_history_async(prior_messages):
|
||||
with self._seed_target_history(prior_messages):
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
|
||||
@@ -3,7 +3,6 @@ ImageAgent - A specialized agent class for generating images using AI models.
|
||||
This class extends the base Agent class to provide specific functionality for image generation,
|
||||
including support for different image models, sizes, and quality settings.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
from ..agent.agent import Agent
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -181,8 +180,8 @@ class ImageAgent(Agent):
|
||||
logging.error(error_msg)
|
||||
raise
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Offload the synchronous generation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.generate_image, prompt, **kwargs)
|
||||
"""Async wrapper for generate_image."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
# Aliases for consistency with other agents
|
||||
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
@@ -191,7 +190,7 @@ class ImageAgent(Agent):
|
||||
|
||||
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Async alias for generate_image()."""
|
||||
return await self.agenerate_image(prompt, **kwargs)
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Generate an image from the prompt."""
|
||||
@@ -303,8 +302,8 @@ class ImageAgent(Agent):
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Offload the synchronous editing path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.edit, image, prompt, mask, n, size, **kwargs)
|
||||
"""Async version of edit()."""
|
||||
return self.edit(image, prompt, mask, n, size, **kwargs)
|
||||
|
||||
def variation(
|
||||
self,
|
||||
@@ -373,5 +372,6 @@ class ImageAgent(Agent):
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Offload the synchronous variation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.variation, image, n, size, **kwargs)
|
||||
"""Async version of variation()."""
|
||||
return self.variation(image, n, size, **kwargs)
|
||||
|
||||
@@ -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)."""
|
||||
@@ -408,26 +347,13 @@ class MemoryMixin:
|
||||
metadata={"role": self.role, "goal": self.goal}
|
||||
)
|
||||
|
||||
# Restore chat history from previous session. Rebuild the full LLM
|
||||
# message shape so a resumed tool-using session hands the model the
|
||||
# same transcript it saw before — assistant turns keep their
|
||||
# ``tool_calls`` and tool-result turns keep their ``tool_call_id``
|
||||
# (Issue #3089 parity for the DB path). Plain turns stay minimal.
|
||||
# Restore chat history from previous session
|
||||
if history:
|
||||
for msg in history:
|
||||
entry = {"role": msg.role, "content": msg.content}
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if tool_calls:
|
||||
# ``DbMessage.tool_calls`` may arrive as provider-shaped
|
||||
# dicts (OpenAI format, the way _persist_message stores
|
||||
# them) or as ``DbToolCall`` dataclasses from an adapter
|
||||
# that follows the declared field type. Normalise to the
|
||||
# provider shape so the next model request always gets a
|
||||
# valid, serialisable tool-call list (Issue #5075).
|
||||
entry["tool_calls"] = self._normalize_restored_tool_calls(tool_calls)
|
||||
if getattr(msg, "tool_call_id", None):
|
||||
entry["tool_call_id"] = msg.tool_call_id
|
||||
self.chat_history.append(entry)
|
||||
self.chat_history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
logging.info(f"Resumed session {self._session_id} with {len(history)} messages")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to initialize DB session: {e}")
|
||||
@@ -435,37 +361,6 @@ class MemoryMixin:
|
||||
self._db_initialized = True
|
||||
self._current_run_id = None # Track current run
|
||||
|
||||
@staticmethod
|
||||
def _normalize_restored_tool_calls(tool_calls):
|
||||
"""Coerce restored tool calls to provider-shaped (OpenAI) dicts.
|
||||
|
||||
Persisted tool calls can come back either already provider-shaped
|
||||
(dicts with ``id``/``type``/``function``) or as ``DbToolCall``
|
||||
dataclasses (``tool_name``/``args``/``call_id``) when an adapter follows
|
||||
the declared ``DbMessage.tool_calls`` field type. Dicts pass through
|
||||
unchanged; dataclasses are converted so the resumed transcript is always
|
||||
a valid, serialisable tool-call list (Issue #5075).
|
||||
"""
|
||||
normalized = []
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict):
|
||||
normalized.append(tc)
|
||||
continue
|
||||
tool_name = getattr(tc, "tool_name", None)
|
||||
if tool_name is not None:
|
||||
import json as _json
|
||||
args = getattr(tc, "args", None)
|
||||
arguments = args if isinstance(args, str) else _json.dumps(args or {})
|
||||
normalized.append({
|
||||
"id": getattr(tc, "call_id", None),
|
||||
"type": "function",
|
||||
"function": {"name": tool_name, "arguments": arguments},
|
||||
})
|
||||
else:
|
||||
# Unknown shape — pass through so we never lose data.
|
||||
normalized.append(tc)
|
||||
return normalized
|
||||
|
||||
def _init_session_store(self):
|
||||
"""
|
||||
Initialize session store for JSON-based persistence (lazy, first chat only).
|
||||
@@ -580,26 +475,7 @@ class MemoryMixin:
|
||||
if role == "user":
|
||||
self._db.on_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
# Faithful transcript: carry any tool calls the assistant
|
||||
# requested so a resumed DB session replays them, matching
|
||||
# the JSON store fix (Issue #3089). Adapters that predate
|
||||
# ``on_assistant_message`` fall back to the text-only
|
||||
# ``on_agent_message`` so behaviour is unchanged for them.
|
||||
if tool_calls and hasattr(self._db, "on_assistant_message"):
|
||||
self._db.on_assistant_message(
|
||||
self._session_id, content, tool_calls=tool_calls
|
||||
)
|
||||
else:
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
elif role == "tool":
|
||||
# Persist the tool-result turn linked to its call id so the
|
||||
# resumed message list interleaves results in order (#3089).
|
||||
# Adapters without ``on_tool_message`` keep the prior
|
||||
# behaviour of dropping the raw tool turn (text-only).
|
||||
if hasattr(self._db, "on_tool_message"):
|
||||
self._db.on_tool_message(
|
||||
self._session_id, content, tool_call_id=tool_call_id
|
||||
)
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message to DB: {e}")
|
||||
return
|
||||
@@ -764,50 +640,6 @@ class MemoryMixin:
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _persist_memory_turn(self, user_message: str, assistant_response: str):
|
||||
"""Persist a raw conversation turn to the memory store.
|
||||
|
||||
Called after each response when ``_memory_instance`` is set but
|
||||
``auto_memory`` extraction is off. Stores the turn to short-term memory
|
||||
so a later Agent sharing the same store (e.g. ``memory={"user_id": uid}``)
|
||||
recalls it via ``get_memory_context()``. Best-effort: never raises.
|
||||
|
||||
Async-safe: a memory store write is blocking file/DB I/O. When this runs
|
||||
inside a live event loop (the async ``achat``/``astart`` after-agent path),
|
||||
the write is offloaded to a worker thread so it never stalls the loop
|
||||
(AGENTS.md §4.5). On the sync path (no running loop) it writes inline.
|
||||
"""
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory is None or not assistant_response:
|
||||
return
|
||||
store = getattr(memory, "store_short_term", None) or getattr(memory, "add_short_term", None)
|
||||
if store is None:
|
||||
return
|
||||
text = f"User: {user_message}\nAssistant: {assistant_response}"
|
||||
metadata = {"agent_id": getattr(self, "agent_id", getattr(self, "name", None))}
|
||||
|
||||
def _do_store():
|
||||
try:
|
||||
store(text, metadata=metadata)
|
||||
except Exception as e:
|
||||
logging.debug(f"Memory turn persistence failed: {e}")
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop (sync chat/run/start path): write inline.
|
||||
_do_store()
|
||||
return
|
||||
# Inside a live loop: offload the blocking write so it cannot stall the
|
||||
# event loop. Fire-and-forget — persistence is best-effort.
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.get_running_loop().run_in_executor(None, _do_store)
|
||||
except Exception as e:
|
||||
logging.debug(f"Memory turn persistence scheduling failed: {e}")
|
||||
_do_store()
|
||||
|
||||
def _process_auto_learning(self):
|
||||
"""Process auto-learning extraction after agent response.
|
||||
|
||||
@@ -898,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 "
|
||||
@@ -928,56 +755,45 @@ class MemoryMixin:
|
||||
if not self.auto_save:
|
||||
return
|
||||
|
||||
# Serialize the whole read-modify-write of _auto_save_last_index. This
|
||||
# method now runs via asyncio.to_thread, so two concurrent async runs
|
||||
# sharing one Agent can execute it in separate worker threads. Without
|
||||
# this lock both could read the same last-saved index, then each persist
|
||||
# the same new turns, duplicating conversation history — the store's
|
||||
# file lock serializes the writes but does not protect this index.
|
||||
lock = getattr(self, '_auto_save_lock', None)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._auto_save_lock = lock
|
||||
with lock:
|
||||
try:
|
||||
# Filter out history markers before saving
|
||||
clean_history = [
|
||||
{k: v for k, v in msg.items() if k != "_from_history"}
|
||||
for msg in self.chat_history
|
||||
]
|
||||
|
||||
# Issue 1 FIX: Only save NEW messages since last save
|
||||
# Track last saved index to avoid duplicates
|
||||
last_saved = getattr(self, '_auto_save_last_index', 0)
|
||||
new_messages = clean_history[last_saved:]
|
||||
|
||||
if not new_messages:
|
||||
return # Nothing new to save
|
||||
|
||||
# G-1 FIX: Use SessionStore for conversation history persistence
|
||||
# This maintains clean separation: Memory = facts, SessionStore = turns
|
||||
if self._session_store is not None:
|
||||
# Persist only NEW messages to SessionStore
|
||||
for msg in new_messages:
|
||||
self._session_store.add_message(
|
||||
self.auto_save, # Use auto_save name as session_id
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
# Update last saved index
|
||||
self._auto_save_last_index = len(clean_history)
|
||||
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
|
||||
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
|
||||
# Fallback to Memory.save_session() for backward compatibility
|
||||
# Memory.save_session() replaces entire history, so no duplicate issue
|
||||
self._memory_instance.save_session(
|
||||
name=self.auto_save,
|
||||
conversation_history=clean_history,
|
||||
metadata={"agent_name": self.name, "user_id": self.user_id}
|
||||
try:
|
||||
# Filter out history markers before saving
|
||||
clean_history = [
|
||||
{k: v for k, v in msg.items() if k != "_from_history"}
|
||||
for msg in self.chat_history
|
||||
]
|
||||
|
||||
# Issue 1 FIX: Only save NEW messages since last save
|
||||
# Track last saved index to avoid duplicates
|
||||
last_saved = getattr(self, '_auto_save_last_index', 0)
|
||||
new_messages = clean_history[last_saved:]
|
||||
|
||||
if not new_messages:
|
||||
return # Nothing new to save
|
||||
|
||||
# G-1 FIX: Use SessionStore for conversation history persistence
|
||||
# This maintains clean separation: Memory = facts, SessionStore = turns
|
||||
if self._session_store is not None:
|
||||
# Persist only NEW messages to SessionStore
|
||||
for msg in new_messages:
|
||||
self._session_store.add_message(
|
||||
self.auto_save, # Use auto_save name as session_id
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error auto-saving session: {e}")
|
||||
# Update last saved index
|
||||
self._auto_save_last_index = len(clean_history)
|
||||
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
|
||||
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
|
||||
# Fallback to Memory.save_session() for backward compatibility
|
||||
# Memory.save_session() replaces entire history, so no duplicate issue
|
||||
self._memory_instance.save_session(
|
||||
name=self.auto_save,
|
||||
conversation_history=clean_history,
|
||||
metadata={"agent_name": self.name, "user_id": self.user_id}
|
||||
)
|
||||
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error auto-saving session: {e}")
|
||||
|
||||
def _save_output_to_file(self, content: str) -> bool:
|
||||
"""Save agent output to file if output_file is configured.
|
||||
|
||||
@@ -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
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Export an Agent or AgentTeam back to a config dict.
|
||||
|
||||
Config flowed one way. ``workflows/yaml_parser.py`` builds agents FROM YAML, and
|
||||
nothing exported them back -- so you could not build a team in Python and hand
|
||||
it to the visual builder, diff two team configs, or check into review what a
|
||||
programmatically-assembled team actually became. This is the missing half of a
|
||||
pipeline that already exists.
|
||||
|
||||
blob = agent_to_dict(agent) # JSON-safe
|
||||
same = agent_from_dict(blob) # an equivalent Agent
|
||||
|
||||
What is deliberately NOT exported: anything that is a live object rather than
|
||||
configuration -- an open session, a memory store, a resolved LLM client. A tool
|
||||
is exported by NAME, because a callable cannot survive a round trip through
|
||||
JSON and pretending otherwise would produce a config that imports as a
|
||||
different agent. ``tools_resolvable`` says whether the names could be rebound,
|
||||
so a caller is told rather than left to discover it.
|
||||
|
||||
Scope, stated plainly so a caller is not surprised: this exports the scalar
|
||||
constructor arguments that are JSON-safe and round-trip cleanly (identity, the
|
||||
model, and simple flags). It does not yet serialise the grouped feature-config
|
||||
objects (``output=``, ``reflection=``, ``execution=`` ...) -- those are rich
|
||||
dataclasses, not scalars, and rebuilding them faithfully is a separate,
|
||||
larger piece of work. An agent that relies on a customised grouped config will
|
||||
round-trip to one carrying that config's *defaults*, not the original. That is
|
||||
a known limit of v1, named here rather than hidden, in the same spirit as
|
||||
``tools_resolvable``.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"AGENT_CONFIG_VERSION",
|
||||
"SerializationError",
|
||||
"agent_to_dict",
|
||||
"agent_from_dict",
|
||||
"team_to_dict",
|
||||
]
|
||||
|
||||
AGENT_CONFIG_VERSION = 1
|
||||
|
||||
#: Candidate fields worth carrying. Deliberately a list rather than "everything
|
||||
#: on the object": an Agent holds runtime state (clients, caches, sessions) that
|
||||
#: is meaningless -- and sometimes unserialisable -- in a config.
|
||||
#:
|
||||
#: Intersected with the CONSTRUCTOR SIGNATURE at export time, so an attribute
|
||||
#: that still exists on the instance but is no longer a constructor argument
|
||||
#: (several moved into `output=`) is not exported into a config that cannot be
|
||||
#: imported. Hardcoding the list alone produced exactly that.
|
||||
_AGENT_FIELDS = (
|
||||
"name", "role", "goal", "backstory", "instructions", "llm",
|
||||
"reasoning_effort",
|
||||
"self_reflect", "max_reflect", "min_reflect", "respect_context_window",
|
||||
"max_iter", "reasoning_steps", "markdown", "stream", "verbose",
|
||||
)
|
||||
|
||||
|
||||
def _constructor_fields(agent_cls: type) -> set:
|
||||
"""The subset of _AGENT_FIELDS this build's constructor still accepts."""
|
||||
import inspect
|
||||
try:
|
||||
params = inspect.signature(agent_cls.__init__).parameters
|
||||
except (TypeError, ValueError): # pragma: no cover - exotic callables
|
||||
return set(_AGENT_FIELDS)
|
||||
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
# **kwargs alone is not proof it is accepted: this class validates and
|
||||
# raises on unknown keys, so trust the named parameters only.
|
||||
pass
|
||||
return {f for f in _AGENT_FIELDS if f in params}
|
||||
|
||||
|
||||
class SerializationError(ValueError):
|
||||
"""Raised when a config cannot be exported or rebuilt."""
|
||||
|
||||
|
||||
def _tool_name(tool: Any) -> Optional[str]:
|
||||
for attr in ("name", "__name__"):
|
||||
value = getattr(tool, attr, None)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if isinstance(tool, str):
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def agent_to_dict(agent: Any) -> Dict[str, Any]:
|
||||
"""An Agent as a JSON-safe config dict."""
|
||||
if agent is None:
|
||||
raise SerializationError("Cannot export None as an agent config.")
|
||||
|
||||
allowed = _constructor_fields(type(agent))
|
||||
config: Dict[str, Any] = {"version": AGENT_CONFIG_VERSION}
|
||||
for field in sorted(allowed):
|
||||
if not hasattr(agent, field):
|
||||
continue
|
||||
value = getattr(agent, field)
|
||||
if value is None:
|
||||
continue
|
||||
# An LLM object rather than a model string: keep its identity, not the
|
||||
# client. A resolved client cannot be rebuilt from JSON anyway.
|
||||
if field == "llm" and not isinstance(value, str):
|
||||
value = getattr(value, "model", None) or getattr(value, "name", None) or str(value)
|
||||
config[field] = value
|
||||
|
||||
tools = getattr(agent, "tools", None) or []
|
||||
names = [_tool_name(t) for t in tools]
|
||||
config["tools"] = [n for n in names if n]
|
||||
# Say plainly whether the tools survived as names. A config whose tools
|
||||
# cannot be rebound imports as an agent that silently cannot act.
|
||||
config["tools_resolvable"] = all(n is not None for n in names)
|
||||
return config
|
||||
|
||||
|
||||
def agent_from_dict(
|
||||
config: Dict[str, Any],
|
||||
*,
|
||||
tool_registry: Optional[Dict[str, Callable]] = None,
|
||||
agent_cls: Optional[type] = None,
|
||||
) -> Any:
|
||||
"""Rebuild an Agent from ``agent_to_dict`` output.
|
||||
|
||||
``tool_registry`` maps exported tool names back to callables. Without it an
|
||||
agent is rebuilt WITHOUT tools, and that is reported rather than assumed --
|
||||
an agent that silently lost its tools looks like an agent that chose not to
|
||||
use them.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
raise SerializationError(f"Agent config must be a dict; got {type(config).__name__}")
|
||||
|
||||
version = config.get("version", AGENT_CONFIG_VERSION)
|
||||
if version != AGENT_CONFIG_VERSION:
|
||||
raise SerializationError(
|
||||
f"Agent config is version {version!r}, this build reads {AGENT_CONFIG_VERSION}."
|
||||
)
|
||||
|
||||
if agent_cls is None:
|
||||
from .agent import Agent as agent_cls # type: ignore
|
||||
|
||||
kwargs = {k: v for k, v in config.items() if k in _constructor_fields(agent_cls)}
|
||||
|
||||
wanted: List[str] = list(config.get("tools") or [])
|
||||
if wanted:
|
||||
if tool_registry is None:
|
||||
raise SerializationError(
|
||||
f"This config declares tools {wanted} but no tool_registry was given, "
|
||||
f"so they cannot be rebound. Pass tool_registry={{name: callable}}, or "
|
||||
f"strip 'tools' from the config if a tool-less agent is intended."
|
||||
)
|
||||
missing = [n for n in wanted if n not in tool_registry]
|
||||
if missing:
|
||||
raise SerializationError(
|
||||
f"tool_registry is missing {missing}. Rebuilding without them would "
|
||||
f"produce an agent that silently cannot act."
|
||||
)
|
||||
kwargs["tools"] = [tool_registry[n] for n in wanted]
|
||||
|
||||
return agent_cls(**kwargs)
|
||||
|
||||
|
||||
def team_to_dict(team: Any) -> Dict[str, Any]:
|
||||
"""An AgentTeam as a config dict, members included.
|
||||
|
||||
A team's *members* export cleanly; its **task graph** does not yet. Tasks
|
||||
carry agent bindings, ordering, and context wiring whose faithful
|
||||
serialisation -- and a matching importer -- is a larger piece of work than
|
||||
this v1. Rather than emit half a task graph that would import wrong, ``tasks``
|
||||
are omitted and ``tasks_included`` reports whether any were dropped, so the
|
||||
caller is told rather than handed a team that silently forgot its work.
|
||||
"""
|
||||
if team is None:
|
||||
raise SerializationError("Cannot export None as a team config.")
|
||||
tasks = getattr(team, "tasks", None)
|
||||
had_tasks = bool(tasks)
|
||||
return {
|
||||
"version": AGENT_CONFIG_VERSION,
|
||||
"name": getattr(team, "name", None),
|
||||
"process": getattr(team, "process", None),
|
||||
"agents": [agent_to_dict(a) for a in (getattr(team, "agents", None) or [])],
|
||||
"tasks_included": not had_tasks,
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Shared HTTP server registry for launch() paths.
|
||||
|
||||
Both ``Agent.launch()`` (single-agent) and ``PraisonAIAgents.launch()``
|
||||
(multi-agent) let multiple ``launch()`` calls share one FastAPI app / uvicorn
|
||||
server per port. They MUST consult the *same* process-wide registry so a
|
||||
standalone agent and a team launched on the same port see each other's state:
|
||||
one "is this port started?" check, one FastAPI app, one set of routes. Keeping
|
||||
two independent registries caused duplicate binds (``Address already in use``)
|
||||
and routes registered through one launcher being invisible on the other's app.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _AgentServerRegistry:
|
||||
"""Encapsulates all shared HTTP server state with proper synchronization."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._started: Dict[int, bool] = {}
|
||||
self._endpoints: Dict[int, Dict[str, str]] = {}
|
||||
self._apps: Dict[int, Any] = {} # FastAPI apps
|
||||
self._ready_events: Dict[int, threading.Event] = {}
|
||||
|
||||
def get_or_create_app(self, port: int, title: str = "AgentTeam API") -> Tuple[Any, bool]:
|
||||
"""Thread-safe app creation. Returns (app, is_new)."""
|
||||
with self._lock:
|
||||
if port not in self._apps:
|
||||
# Lazy import to avoid optional dependency at module level
|
||||
from fastapi import FastAPI
|
||||
self._apps[port] = FastAPI(title=title)
|
||||
self._endpoints[port] = {}
|
||||
self._ready_events[port] = threading.Event()
|
||||
return self._apps[port], True
|
||||
return self._apps[port], False
|
||||
|
||||
def register_route(self, port: int, path: str, endpoint_id: str = "registered") -> None:
|
||||
"""Thread-safe route registration tracking."""
|
||||
with self._lock:
|
||||
if port not in self._endpoints:
|
||||
self._endpoints[port] = {}
|
||||
self._endpoints[port][path] = endpoint_id
|
||||
|
||||
def reserve_route(self, port: int, path: str, endpoint_id: str) -> Tuple[str, Optional[str]]:
|
||||
"""Atomically reserve and register a route.
|
||||
|
||||
Returns:
|
||||
tuple[str, Optional[str]]: (final_path, original_path_if_collided).
|
||||
If there is no collision, final_path equals the requested path and
|
||||
original_path_if_collided is None.
|
||||
"""
|
||||
with self._lock:
|
||||
if port not in self._endpoints:
|
||||
self._endpoints[port] = {}
|
||||
|
||||
original_path = path
|
||||
while path in self._endpoints[port]:
|
||||
path = f"{original_path}_{str(uuid.uuid4())[:6]}"
|
||||
|
||||
self._endpoints[port][path] = endpoint_id
|
||||
return path, (original_path if path != original_path else None)
|
||||
|
||||
def list_routes(self, port: int) -> List[str]:
|
||||
"""Return a snapshot list of registered routes for a port."""
|
||||
with self._lock:
|
||||
return list(self._endpoints.get(port, {}).keys())
|
||||
|
||||
def unregister_routes_for(self, port: int, endpoint_id: str) -> List[str]:
|
||||
"""Remove all routes owned by ``endpoint_id`` on ``port``.
|
||||
|
||||
Returns the list of removed paths so callers can tear down the matching
|
||||
FastAPI routes. Thread-safe.
|
||||
"""
|
||||
with self._lock:
|
||||
paths = self._endpoints.get(port, {})
|
||||
removed = [p for p, owner in paths.items() if owner == endpoint_id]
|
||||
for p in removed:
|
||||
del paths[p]
|
||||
if not paths:
|
||||
self._endpoints.pop(port, None)
|
||||
return removed
|
||||
|
||||
def teardown_routes_for(self, endpoint_id: str) -> None:
|
||||
"""Remove every route owned by ``endpoint_id`` across all ports.
|
||||
|
||||
Performs BOTH the endpoint-registry removal AND the matching FastAPI
|
||||
route teardown while holding ``self._lock`` for each port, so it cannot
|
||||
interleave with a concurrent ``reserve_route`` + ``app.post()`` for the
|
||||
same path (which would otherwise let this filter delete a freshly
|
||||
registered live endpoint, or race the ``_apps`` snapshot). Only the
|
||||
agent-owned POST route at each path is dropped; a co-located GET (e.g.
|
||||
the built-in ``/`` and ``/health``) is preserved.
|
||||
"""
|
||||
for port in list(self._apps.keys()):
|
||||
with self._lock:
|
||||
paths = self._endpoints.get(port, {})
|
||||
removed = [p for p, owner in paths.items() if owner == endpoint_id]
|
||||
if not removed:
|
||||
continue
|
||||
for p in removed:
|
||||
del paths[p]
|
||||
if not paths:
|
||||
self._endpoints.pop(port, None)
|
||||
|
||||
app = self._apps.get(port)
|
||||
if app is None:
|
||||
continue
|
||||
removed_set = set(removed)
|
||||
try:
|
||||
app.router.routes = [
|
||||
r for r in app.router.routes
|
||||
if not (
|
||||
getattr(r, "path", None) in removed_set
|
||||
and "POST" in (getattr(r, "methods", None) or set())
|
||||
)
|
||||
]
|
||||
# Invalidate cached OpenAPI schema so removed routes
|
||||
# disappear from /openapi.json and /docs.
|
||||
app.openapi_schema = None
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to tear down routes for %s on port %s",
|
||||
endpoint_id,
|
||||
port,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def start_server_if_needed(self, port: int, host: str = "0.0.0.0", **kwargs) -> bool: # noqa: S104
|
||||
"""Start server with proper readiness signaling. Returns True if server was started.
|
||||
|
||||
Readiness is signalled only *after* uvicorn has actually bound the
|
||||
socket (``Server.started``), so a caller that observes readiness can
|
||||
immediately issue a request without a connection-refused race. If the
|
||||
bind fails (e.g. ``Address already in use``) the port's ``_started``
|
||||
flag is cleared so a later ``launch()`` can retry the bind instead of
|
||||
registering routes on an app whose server never came up.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._started.get(port, False):
|
||||
return False # Already started
|
||||
self._started[port] = True
|
||||
app = self._apps.get(port)
|
||||
|
||||
if not app:
|
||||
# Roll back the reservation so a caller that first registers an app
|
||||
# can start it — we must not leave the port permanently "started".
|
||||
with self._lock:
|
||||
self._started[port] = False
|
||||
raise ValueError(f"No app registered for port {port}")
|
||||
|
||||
ready_event = self._ready_events[port]
|
||||
|
||||
def run_server():
|
||||
try:
|
||||
import uvicorn
|
||||
# Remove hardcoded log_level to avoid conflict with kwargs
|
||||
config = uvicorn.Config(app, host=host, port=port, **kwargs)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# Signal readiness only once uvicorn reports the socket is bound
|
||||
# (server.started flips True after startup completes). A watcher
|
||||
# thread polls that flag and sets the event, so waiters are not
|
||||
# told "ready" before the port actually accepts connections.
|
||||
def _signal_when_bound():
|
||||
while not server.started:
|
||||
if ready_event.is_set():
|
||||
return # bind failed / server exited before startup
|
||||
time.sleep(0.02)
|
||||
ready_event.set()
|
||||
|
||||
watcher = threading.Thread(target=_signal_when_bound, daemon=True)
|
||||
watcher.start()
|
||||
server.run()
|
||||
except Exception:
|
||||
# Bind/startup failed. Clear the started flag so a subsequent
|
||||
# launch() can re-attempt binding instead of silently
|
||||
# registering routes on a dead server, and unblock any waiter.
|
||||
logger.warning(
|
||||
"Agent server on port %s failed to start.", port, exc_info=True
|
||||
)
|
||||
with self._lock:
|
||||
self._started[port] = False
|
||||
finally:
|
||||
# Always release the waiter: on failure the started flag has been
|
||||
# cleared above, so the (now-false) readiness is correctly
|
||||
# observable via the flag rather than a hung wait().
|
||||
ready_event.set()
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Check for configurable timeout via environment variable
|
||||
try:
|
||||
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
|
||||
except ValueError:
|
||||
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
|
||||
timeout = 5.0
|
||||
became_ready = ready_event.wait(timeout=timeout)
|
||||
|
||||
if not became_ready:
|
||||
logger.warning(
|
||||
"Agent server on port %s did not become ready within %.1fs. "
|
||||
"Proceeding, but some features may not work correctly. "
|
||||
"Check server logs for startup errors.",
|
||||
port,
|
||||
timeout,
|
||||
)
|
||||
|
||||
# Report whether the bind actually succeeded: on failure run_server has
|
||||
# cleared the flag, so returning it lets callers distinguish a live
|
||||
# server from a poisoned port.
|
||||
with self._lock:
|
||||
return self._started.get(port, False)
|
||||
|
||||
|
||||
# Module level — single registry instance shared by every launch() call site.
|
||||
_server_registry = _AgentServerRegistry()
|
||||
Loaded 100 of 1930 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user