Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison af4d4c399d style: fix indentation in bundled default_app write_text call
Co-authored-by: Mervin Praison <[email protected]>
2026-07-25 16:28:46 +00:00
2282 changed files with 35017 additions and 370007 deletions

No files matched your search

+330
View File
@@ -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 &amp; 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 &lt;cmd&gt;</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&gt;=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 -->
+460
View File
@@ -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 &amp; 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 &amp; 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 -->
-1
View File
@@ -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
+1 -7
View File
@@ -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,5 +1,5 @@
name: Install PraisonAI monorepo packages
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, praisonai-deploy, and praisonai wrapper from local paths (mirrors PyPI nine-package install order).
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, and praisonai wrapper from local paths (mirrors PyPI eight-package install order).
inputs:
repo-root:
@@ -40,7 +40,6 @@ runs:
BROWSER_PKG="${BASE}src/praisonai-browser"
MCP_PKG="${BASE}src/praisonai-mcp"
SANDBOX_PKG="${BASE}src/praisonai-sandbox"
DEPLOY_PKG="${BASE}src/praisonai-deploy"
WRAPPER_PKG="${BASE}src/praisonai"
normalise_extras() {
@@ -70,8 +69,6 @@ runs:
uv pip install --system -e .
cd "$SANDBOX_PKG"
uv pip install --system -e .
cd "$DEPLOY_PKG"
uv pip install --system -e .
cd "$WRAPPER_PKG"
uv pip install --system "${WRAPPER_SUFFIX}"
else
@@ -89,8 +86,6 @@ runs:
pip install -e .
cd "$SANDBOX_PKG"
pip install -e .
cd "$DEPLOY_PKG"
pip install -e .
cd "$WRAPPER_PKG"
pip install -e "${WRAPPER_SUFFIX}"
fi
+2 -4
View File
@@ -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
+1 -1
View File
@@ -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) {
+2 -94
View File
@@ -213,33 +213,6 @@ assert(
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
assert(
'scan kickoff text is not a verdict',
mg.findMergeGateVerdict([
{
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
created_at: '2026-06-27T10:05:00Z',
},
], null, '2026-06-27T09:55:00Z') === null
);
const finalWithFinished = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T10:05:00Z' },
];
assert('final complete requires Claude finished reply', mg.finalClaudeCompletedOnSha(finalWithFinished, '2026-06-27T09:55:00Z'));
assert('final trigger alone is incomplete', !mg.finalClaudeCompletedOnSha(
[{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' }],
'2026-06-27T09:55:00Z'
));
assert('recent scan comment detected', mg.hasRecentMergeGateScanComment([
{ body: '**Merge gate scan** — eligible', created_at: new Date().toISOString() },
]));
assert('old scan comment not recent', !mg.hasRecentMergeGateScanComment([
{ body: '**Merge gate scan** — eligible', created_at: '2020-01-01T00:00:00Z' },
]));
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
@@ -284,13 +257,8 @@ const finalAfterRebase = {
body: '@claude You are the FINAL architecture reviewer.',
created_at: conflictIso(-20 * 60 * 1000),
};
const claudeFinishedAfterRebase = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Claude finished',
created_at: conflictIso(-19 * 60 * 1000),
};
const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase, claudeFinishedAfterRebase];
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
@@ -304,16 +272,6 @@ assert('sdk with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
]) === null);
assert('ts sdk without tests', mg.missingTestsReason([
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
]) !== null);
assert('ts sdk with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
{ filename: 'src/praisonai-ts/tests/unit/agent/history-restore.test.ts', additions: 10 },
]) === null);
assert('touches ts sdk', mg.touchesSdk([{ filename: 'src/praisonai-ts/src/agent/simple.ts' }]));
assert('final body requires sdk value table', mg.FINAL_CLAUDE_REVIEW_BODY.includes('| **SDK value** |'));
assert('final body requires ts agents md', mg.FINAL_CLAUDE_REVIEW_BODY.includes('src/praisonai-ts/AGENTS.md'));
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
@@ -329,54 +287,4 @@ assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
'PraisonAI'
));
assert('dispatch blocked when merge gate active', mg.mergeGateDispatchBlockedReason({
labels: ['claude-merge-gate-active'],
pendingRuns: 0,
}) === 'merge gate already active on PR');
assert('dispatch blocked when queue saturated', mg.mergeGateDispatchBlockedReason({
labels: [],
pendingRuns: 3,
})?.includes('already queued or in progress'));
assert('dispatch allowed when queue below cap', mg.mergeGateDispatchBlockedReason({
labels: [],
pendingRuns: 2,
}) == null);
// Pending count excludes workflow_run auto-dispatch probes (PR #4534)
assert('workflow_run probes excluded from pending count', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_dispatch' },
{ event: 'workflow_run' },
{ event: 'workflow_run' },
]) === 1);
assert('all-probe runs count as zero pending', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_run' },
{ event: 'workflow_run' },
]) === 0);
assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_dispatch' },
{ event: 'schedule' },
]) === 2);
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
(async () => {
const skipLow = await mg.shouldSkipMergeGateDispatch(
{
rest: {
rateLimit: {
get: async () => ({ data: { resources: { core: { remaining: 50 } } } }),
},
},
},
'owner',
'repo',
1,
{ info: () => {} }
);
assert('rate limit low skips dispatch', skipLow === true);
process.exit(failed ? 1 : 0);
})().catch((err) => {
console.error(err);
process.exit(1);
});
process.exit(failed ? 1 : 0);
+13 -189
View File
@@ -17,8 +17,7 @@ const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/', 'src/praisonai-ts/'];
const TS_SDK_SOURCE_PREFIX = 'src/praisonai-ts/src/';
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
const SENSITIVE_PATH_PATTERNS = [
/^\.github\/workflows\//,
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
@@ -27,17 +26,7 @@ const SENSITIVE_PATH_PATTERNS = [
/\.env(\.|$)/,
/credentials\.json$/i,
];
const REQUIRED_SDK_CHECK_PATTERNS = [
/test/i,
/smoke/i,
/core/i,
/python package/i,
/comprehensive/i,
/optimized/i,
/praisonai-ts/i,
/npm/i,
/webview/i,
];
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
@@ -85,11 +74,7 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
if (!login.includes('praisonai-triage')) return false;
return (c.body || '').includes('Claude finished');
}
@@ -111,47 +96,15 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
body.includes('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
@@ -292,9 +245,6 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
@@ -315,20 +265,11 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
return comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Review changes in this PR. Python SDK: praisonaiagents, praisonai. TypeScript SDK: src/praisonai-ts/. Do NOT modify src/praisonai-rust. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**MANDATORY READ (before reviewing):**\n- Always read src/praisonai-agents/AGENTS.md\n- If this PR touches src/praisonai-ts/, also read src/praisonai-ts/AGENTS.md §2.1.2 (TS triage + PR review checklist)\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment:**\n#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|\n| Protocol-driven / no heavy impl in core | ✅ or ❌ + one-line rationale |\n| Backward compatible | ✅ or ❌ + one-line rationale |\n| Performance (hot path) | ✅ or ❌ + one-line rationale |\n| **SDK value** | ✅ or ❌ + one-line rationale (explicitly judge whether the change strengthens the SDK) |\n| No Agent param bloat | ✅ or ❌ + one-line rationale |\n| Repo routing | ✅ or ❌ + one-line rationale |\n\nFor TypeScript PRs (src/praisonai-ts/), also add:\n| TS types / parity / tests | ✅ or ❌ + one-line rationale (npm run build && npm test) |\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
@@ -484,79 +425,6 @@ function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
const MERGE_GATE_WORKFLOW_FILE = 'claude-merge-gate.yml';
const MERGE_GATE_ACTIVE_LABEL = 'claude-merge-gate-active';
const MAX_PENDING_MERGE_GATE_RUNS = 3;
const MIN_CORE_RATE_LIMIT_REMAINING = 200;
function mergeGateDispatchBlockedReason({ labels, pendingRuns, maxPending = MAX_PENDING_MERGE_GATE_RUNS }) {
if ((labels || []).includes(MERGE_GATE_ACTIVE_LABEL)) {
return 'merge gate already active on PR';
}
if (pendingRuns >= maxPending) {
return `${pendingRuns} merge gate run(s) already queued or in progress (max ${maxPending})`;
}
return null;
}
// workflow_run probes (auto-dispatch) share concurrency and must not count
// toward backpressure — they blocked dispatches for merge-ready PRs.
function countSubstantiveMergeGateRuns(runs) {
return (runs || []).filter((r) => r && r.event !== 'workflow_run').length;
}
async function countPendingMergeGateRuns(github, owner, repo) {
let pending = 0;
for (const status of ['queued', 'in_progress']) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: MERGE_GATE_WORKFLOW_FILE,
status,
per_page: 30,
});
pending += countSubstantiveMergeGateRuns(data.workflow_runs);
} catch {
// Best-effort backpressure only.
}
}
return pending;
}
async function getCoreRateLimitRemaining(github) {
try {
const { data } = await github.rest.rateLimit.get();
return data?.resources?.core?.remaining ?? null;
} catch {
return null;
}
}
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
const remaining = await getCoreRateLimitRemaining(github);
if (remaining !== null && remaining < minCoreRemaining) {
core?.info?.(
`Skip merge gate dispatch for PR #${prNumber}: rate limit low (${remaining} core remaining)`
);
return true;
}
const ctx = await loadPrContext(github, owner, repo, prNumber);
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
const reason = mergeGateDispatchBlockedReason({
labels: ctx.labels,
pendingRuns,
maxPending,
});
if (reason) {
core?.info?.(`Skip merge gate dispatch for PR #${prNumber}: ${reason}`);
return true;
}
return false;
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
@@ -720,35 +588,18 @@ function prSizeReasons(files) {
}
function missingTestsReason(files) {
const pySdkAdds = files.filter(
const sdkAdds = files.filter(
(f) =>
f.filename.startsWith('src/praisonai-agents/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
const tsSdkAdds = files.filter(
(f) =>
f.filename.startsWith(TS_SDK_SOURCE_PREFIX) &&
f.filename.endsWith('.ts') &&
!f.filename.endsWith('.d.ts') &&
(f.additions || 0) > 0
);
if (pySdkAdds.length === 0 && tsSdkAdds.length === 0) return null;
const hasPyTestChange = files.some(
if (sdkAdds.length === 0) return null;
const hasTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
const hasTsTestChange = files.some(
(f) =>
f.filename.startsWith('src/praisonai-ts/tests/') ||
/\.(test|spec)\.ts$/.test(f.filename)
);
if (pySdkAdds.length > 0 && !hasPyTestChange) {
return 'Python SDK code added without test file changes — requires manual review';
}
if (tsSdkAdds.length > 0 && !hasTsTestChange) {
return 'TS SDK code added without test file changes — requires manual review';
}
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
return null;
}
@@ -1029,33 +880,18 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
}
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
const MERGE_GATE_SCAN_MARKER = 'Merge gate scan';
function isAutomatedFallbackVerdict(body) {
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
}
function isMergeGateVerdictComment(body) {
const text = body || '';
if (text.includes(MERGE_GATE_SCAN_MARKER)) return false;
return /(?:^|\n)\s*MERGE_GATE_VERDICT:\s*(APPROVE|BLOCK)\b/m.test(text);
}
function hasRecentMergeGateScanComment(comments, withinMinutes = 30) {
const cutoff = Date.now() - withinMinutes * 60 * 1000;
return (comments || []).some((c) => {
if (!(c.body || '').includes(MERGE_GATE_SCAN_MARKER)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
const { excludeAutomatedFallback = false } = options || {};
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!isMergeGateVerdictComment(c.body)) return false;
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
@@ -1065,8 +901,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;
}
@@ -1080,7 +916,6 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
@@ -1137,16 +972,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,
};
+2 -2
View File
@@ -181,10 +181,10 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
const mergeGate = require('./merge-gate.js');
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, cand.prNumber, core)) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
continue;
}
await github.rest.repos.createDispatchEvent({
+22 -87
View File
@@ -11,112 +11,47 @@ assert.ok(versions.currentCode);
assert.ok(versions.currentWrapper);
assert.ok(versions.targetCode);
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
// Every released package must make the gate's path-change check eligible.
for (const p of [
'src/praisonai', 'src/praisonai-agents', 'src/praisonai-code',
'src/praisonai-bot', 'src/praisonai-train', 'src/praisonai-browser',
'src/praisonai-mcp', 'src/praisonai-sandbox', 'src/praisonai-deploy',
]) {
assert.ok(rg.PACKAGE_PATHS.includes(p), `PACKAGE_PATHS missing ${p}`);
}
const noonUtc = new Date('2026-07-08T12:00:00Z');
const dayStart = rg.utcDayStart(noonUtc);
assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
function mockGithub({ runs = [], releases = [] } = {}) {
return {
rest: {
actions: {
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
},
repos: {
listReleases: async () => ({ data: releases }),
},
},
};
}
(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
// Dedupe is release-based: a v* release inside the window blocks…
const recentRelease = mockGithub({
releases: [{ tag_name: 'v4.6.159', published_at: '2026-07-08T09:00:00Z' }],
});
const mockGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(recentRelease, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseToday(mockGithub, 'o', 'r', noonUtc),
true
);
const result = await rg.evaluateReleasePreflight(
recentRelease, 'o', 'r',
mockGithub, 'o', 'r',
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
// …an old release does not…
const oldRelease = mockGithub({
releases: [{ tag_name: 'v4.6.150', published_at: '2026-07-04T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldRelease, 'o', 'r', noonUtc),
false
);
// …and a dry-run "success" (workflow run, no release) does not consume the
// window: only real releases count.
const dryRunOnly = mockGithub({
runs: [{ conclusion: 'success', created_at: '2026-07-08T09:00:00Z', status: 'completed' }],
releases: [],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(dryRunOnly, 'o', 'r', noonUtc),
false
);
// Active-run blocking: fresh waiting run blocks…
const freshWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T11:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(freshWaiting, 'o', 'r', noonUtc, null),
true
);
// …a waiting run stuck longer than STALE_WAITING_HOURS no longer blocks…
const staleWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T01:00:00Z', html_url: 'x' }],
});
const warnings = [];
const fakeCore = { warning: (m) => warnings.push(m), info: () => {} };
assert.strictEqual(rg.STALE_WAITING_HOURS, 6);
assert.strictEqual(
await rg.hasActiveReleaseRun(staleWaiting, 'o', 'r', noonUtc, fakeCore),
false
);
assert.ok(warnings.length === 1 && warnings[0].includes('approve or cancel'));
// …but an in_progress run always blocks regardless of age.
const oldInProgress = mockGithub({
runs: [{ status: 'in_progress', conclusion: null, created_at: '2026-07-07T01:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(oldInProgress, 'o', 'r', noonUtc, null),
true
);
assert.ok(result.reasons.some((r) => r.includes('already released today')));
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: PACKAGE_PATHS covers all 9 released packages');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: release-based dedupe (recent blocks, old does not)');
console.log('ok: dry-run success does not consume the release window');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: fresh waiting run blocks; stale waiting run ignored with warning');
console.log('ok: in_progress always blocks');
console.log('ok: hasSuccessfulReleaseToday');
console.log('ok: daily dedupe blocks second release');
})();
+20 -105
View File
@@ -4,31 +4,10 @@
const https = require('https');
/** Every directory whose changes should make a release eligible — all nine
* published packages (mirrors the skip_* inputs in pypi-release.yml). */
const PACKAGE_PATHS = [
'src/praisonai',
'src/praisonai-agents',
'src/praisonai-code',
'src/praisonai-bot',
'src/praisonai-train',
'src/praisonai-browser',
'src/praisonai-mcp',
'src/praisonai-sandbox',
'src/praisonai-deploy',
];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
/** A run stuck in `waiting` (environment approval) longer than this no longer
* blocks the gate. GitHub only auto-fails unapproved deployments after 30
* days, so without a cutoff one forgotten manual dispatch stalls all
* auto-releases for up to a month. The run is warned about, never cancelled —
* if later approved, the pypi-release concurrency group serializes it and its
* pypi_exists checks no-op anything already published. */
const STALE_WAITING_HOURS = 6;
function bumpPatch(version) {
const parts = version.split('.');
@@ -84,65 +63,36 @@ function pypiVersionExists(packageName, version) {
});
}
async function hasActiveReleaseRun(github, owner, repo, now = new Date(), core = null) {
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
const staleCutoff = now.getTime() - STALE_WAITING_HOURS * 60 * 60 * 1000;
return runs.data.workflow_runs.some((r) => {
if (!ACTIVE_RELEASE_STATUSES.has(r.status) || r.conclusion) return false;
if (r.status === 'waiting' && new Date(r.created_at).getTime() < staleCutoff) {
if (core) {
core.warning(
`Ignoring release run waiting >${STALE_WAITING_HOURS}h for environment approval: `
+ `${r.html_url} — approve or cancel it. It no longer blocks auto-releases.`
);
}
return false;
}
return true;
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
// Dedupe on GitHub releases (v* tags) rather than run conclusions: a
// dry_run=true dispatch concludes 'success' without publishing anything and
// must not consume the release window. Only a real full release creates a
// v* GitHub release (bump_and_release.py's `gh release create`).
const releases = await github.rest.repos.listReleases({
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
per_page: 10,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
});
return releases.data.some(
(r) => r.tag_name && r.tag_name.startsWith('v')
&& new Date(r.published_at || r.created_at) >= windowStart
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -179,18 +129,15 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasActiveReleaseRun(github, owner, repo, referenceTime, core)) {
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
@@ -260,35 +207,8 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
// Core Tests has path filters and honors [skip ci] (the release
// safety-net commit uses it), so the tip of main may legitimately have
// no Core Tests run of its own — e.g. a docs-only commit, or the
// "chore(release): … [skip ci]" commit right after a release. Accept a
// green ancestor when nothing under the released package paths changed
// after it; otherwise the cron path stalls until the next src/** push.
let greenAncestorOk = false;
if (greenSha && /^[0-9a-f]{40}$/.test(greenSha)) {
try {
execSync(`git merge-base --is-ancestor ${greenSha} ${evalSha}`);
const delta = execSync(
`git diff --name-only ${greenSha} ${evalSha} -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
greenAncestorOk = delta === '';
} catch {
greenAncestorOk = false;
}
}
if (!greenAncestorOk) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
if (core) {
core.info(
`HEAD ${evalSha.slice(0, 7)} has no Core Tests run; accepting green ancestor `
+ `${greenSha.slice(0, 7)} (no package-path changes since).`
);
}
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
@@ -302,11 +222,6 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
STALE_WAITING_HOURS,
releaseIntervalStart,
hasActiveReleaseRun,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+1 -1
View File
@@ -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'
});
+30 -125
View File
@@ -33,8 +33,7 @@ on:
- claude-merge-gate
concurrency:
# Serialize workflow_run auto-dispatches (Core Tests / Claude Assistant fan-out).
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || (github.event_name == 'workflow_run' && 'auto-dispatch') || 'scan' }}
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
cancel-in-progress: false
env:
@@ -108,10 +107,6 @@ jobs:
return;
}
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core)) {
return;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
@@ -201,26 +196,17 @@ jobs:
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
'**Opt out:** label `no-auto-merge`.',
].join('\n');
const ctx = await mergeGate.loadPrContext(github, owner, repo, skipped[0].pr);
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
}
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
} else if (inputPr && candidates.length === 1) {
const prNum = candidates[0].pr_number;
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNum);
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge on an APPROVE verdict.',
});
}
await github.rest.issues.createComment({
owner, repo, issue_number: candidates[0].pr_number,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
});
}
await ps.syncOpenPullRequests(
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
);
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
claude-assess:
needs: scan-candidates
@@ -276,7 +262,6 @@ jobs:
- name: Claude merge gate assessment (read-only)
id: assess
continue-on-error: true
uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -303,7 +288,7 @@ jobs:
Review checklist:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens the relevant SDK (Python: praisonaiagents/praisonai; TypeScript: src/praisonai-ts/) — no scope creep. The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it. FINAL architecture review must include the Phase 1 table with an explicit **SDK value** row.
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
@@ -318,97 +303,15 @@ jobs:
Write
Replace
allowed_tools: |
mcp__github__add_issue_comment
mcp__github__get_pull_request
mcp__github__get_pull_request_files
mcp__github__get_pull_request_reviews
mcp__github__get_pull_request_status
mcp__github__get_issue
mcp__github__get_issue_comments
Glob
Grep
View
GlobTool
GrepTool
Read
timeout_minutes: 15
- name: Post Opus verdict from assess output if MCP missed
id: relay_verdict
if: always() && steps.assess.conclusion != 'skipped'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const fs = require('fs');
const mergeGate = require('/tmp/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const existing = mergeGate.findMergeGateVerdict(
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Opus verdict already on PR: ${existing}`);
return;
}
const outPath = `${process.env.GITHUB_WORKSPACE}/.claude-assess-output.jsonl`;
if (!fs.existsSync(outPath)) {
core.info('No assess output file to relay');
return;
}
const lines = fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
let verdict = null;
let rationale = '';
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.includes('MERGE_GATE_VERDICT: APPROVE')) {
verdict = 'APPROVE';
rationale = line;
break;
}
if (line.includes('MERGE_GATE_VERDICT: BLOCK')) {
verdict = 'BLOCK';
rationale = line;
break;
}
try {
const evt = JSON.parse(line);
const text = JSON.stringify(evt);
if (text.includes('MERGE_GATE_VERDICT: APPROVE')) {
verdict = 'APPROVE';
rationale = text;
break;
}
if (text.includes('MERGE_GATE_VERDICT: BLOCK')) {
verdict = 'BLOCK';
rationale = text;
break;
}
} catch {
// Not JSON — keep scanning.
}
}
if (!verdict) {
core.info('Assess output had no MERGE_GATE_VERDICT to relay');
return;
}
const body = [
`MERGE_GATE_VERDICT: ${verdict}`,
'',
'Relayed from Opus merge gate assess output (GitHub MCP comment post was unavailable).',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body,
});
core.info(`Relayed Opus verdict to PR comment: ${verdict}`);
- name: Post fallback verdict if Opus did not comment
- name: Post fallback BLOCK if Opus did not comment
id: fallback_verdict
if: always() && steps.assess.conclusion != 'skipped'
if: steps.assess.outcome != 'success'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -424,21 +327,20 @@ jobs:
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Verdict already posted: ${existing}`);
core.info(`Opus verdict already posted: ${existing}`);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
const lines = [
`MERGE_GATE_VERDICT: ${verdict}`,
'MERGE_GATE_VERDICT: BLOCK',
'',
'Automated fallback — Opus merge gate did not post a PR comment.',
'Automated fallback — Opus merge gate assessment did not complete.',
];
if (check.ready) {
lines.push('All deterministic merge gates passed.');
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
} else {
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
}
@@ -446,10 +348,9 @@ jobs:
owner, repo, issue_number: prNumber,
body: lines.join('\n'),
});
core.info(`Posted fallback verdict: ${verdict}`);
core.info('Posted fallback BLOCK — Opus verdict required');
- name: Capture Opus MERGE_GATE_VERDICT
id: capture_verdict
- name: Require Opus MERGE_GATE_VERDICT
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -465,14 +366,14 @@ jobs:
const verdict = mergeGate.findMergeGateVerdict(
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
core.setOutput('verdict', verdict || 'NONE');
if (!verdict) {
core.warning(
'Opus MERGE_GATE_VERDICT not posted on PR (GitHub MCP tools may be blocked). Merge waits for APPROVE.'
core.setFailed(
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
);
} else {
core.info(`Opus merge gate verdict: ${verdict}`);
return;
}
core.info(`Opus merge gate verdict: ${verdict}`);
core.setOutput('verdict', verdict);
- name: Remove merge gate active label
if: always()
@@ -493,6 +394,10 @@ jobs:
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
}
- name: Fail job if assessment failed
if: steps.assess.outcome == 'failure'
run: exit 1
merge-only:
needs: [scan-candidates, claude-assess]
if: |
+9 -16
View File
@@ -100,19 +100,18 @@ jobs:
'',
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
'',
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins). TypeScript/JavaScript SDK fixes land in `src/praisonai-ts/` in this repo.'
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
].join('\n')
});
}
// --- Auto-trigger Claude for bug/enhancement reports ---
// --- Auto-trigger Claude for owner issues and actionable external bug/enhancement reports ---
const titleLower = (issue.title || '').toLowerCase();
const bodyRaw = issue.body || '';
const hasBugMarker = /#?\s*\[bug\]/i.test(issue.title || '') || /#?\s*\[bug\]/i.test(bodyRaw);
const hasActionableTitle = /^(\[[^\]]+\]|bug:|fix[:(]|feat:|ux:|enhancement:)/i.test(titleLower);
const isBugReport = labels.includes('bug') || hasBugMarker;
const isEnhancement = labels.includes('enhancement') && !labels.includes('question');
const autoClaude = isOwner || isBugReport || (hasActionableTitle && isEnhancement);
const autoClaude = isOwner || (hasActionableTitle && (
labels.includes('bug') ||
(labels.includes('enhancement') && !labels.includes('question'))
));
if (autoClaude) {
await github.rest.issues.addLabels({
issue_number: issue.number,
@@ -294,7 +293,7 @@ jobs:
STEP 1 — READ GUIDELINES:
Read AGENTS.md to understand the architecture rules.
SCOPE: Python SDK (praisonaiagents, praisonai) and TypeScript SDK (src/praisonai-ts/) live in this monorepo. Do NOT modify src/praisonai-rust. For TS issues read src/praisonai-ts/AGENTS.md.
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage (issue triage, fix planning, PR review/implementation) and reject scope creep for the sake of adding features. Prefer existing Agent capabilities (instructions/backstory/tools/hooks/memory) over new params/modules/exports. Even when fixing a raised issue, keep the fix minimal and lightweight — do NOT expand the surface area or add knobs with no live consumer.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
@@ -306,7 +305,6 @@ jobs:
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
5. Plugins → MervinPraison/PraisonAI-Plugins: framework lifecycle extensions (tracing, logging, metrics, hooks, guardrails). Not agent tools — use STEP 3-ALT.
6. Sandbox backends (optional/niche, e.g. Capsule) → PraisonAI-Plugins via `praisonai.sandbox` entry point; core keeps protocol/config/manager only.
7. TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (not praisonaiagents/).
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory). If it does, reject the change as scope creep and prefer the existing capability — the aim is lightweight and powerful, not more knobs.
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
@@ -338,11 +336,9 @@ jobs:
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
<body>"
h) Comment on THIS PraisonAI issue with the external PR URL.
STEP 4 — TEST:
- For Python SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For TypeScript SDK changes: cd src/praisonai-ts && npm install && npm run build && npm test
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
@@ -355,8 +351,6 @@ jobs:
Bash(conda:*)
Bash(pytest:*)
Bash(gh:*)
Bash(npm:*)
Bash(node:*)
Bash(python -m pytest:*)
Bash(python -m pip:*)
Bash(poetry:*)
@@ -376,10 +370,9 @@ jobs:
steps.check_fork.outputs.is_pr != 'true' &&
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
github.event.issue != null
continue-on-error: true
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
-267
View File
@@ -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"
-115
View File
@@ -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
-351
View File
@@ -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
-164
View File
@@ -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
-263
View File
@@ -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 }}
+4 -8
View File
@@ -1,12 +1,12 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Triggers: Core Tests success on main (workflow_run) or cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
# Triggers: Core Tests success on main (workflow_run) or nightly cron at 00:00 UTC.
# Dedupe: max one successful patch release per UTC day (see release-gate.js).
on:
schedule:
- cron: '0 0 */3 * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
@@ -41,7 +41,7 @@ jobs:
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
!startsWith(github.event.workflow_run.head_commit.message, 'Bump praisonai')
)
runs-on: ubuntu-latest
env:
@@ -69,10 +69,6 @@ jobs:
- name: Fetch main and tags
run: git fetch --tags origin main
# Guards the gate logic itself — no workflow ran these selftests before.
- name: Selftest release-gate script
run: node .github/scripts/release-gate-selftest.js
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
-106
View File
@@ -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
-70
View File
@@ -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
+170 -427
View File
@@ -61,29 +61,6 @@ on:
required: false
default: false
type: boolean
skip_deploy:
description: 'Skip praisonai-deploy publish (recovery if already on PyPI)'
required: false
default: false
type: boolean
# NOTE: GitHub caps workflow_dispatch at 25 top-level inputs; this
# workflow has 23. Adding a 10th package (skip_* + *_version) hits 25.
only:
description: 'Release ONLY this package (forces skip on all others); all = no restriction'
required: false
default: all
type: choice
options:
- all
- agents
- code
- bot
- train
- browser
- mcp
- sandbox
- deploy
- wrapper
agents_version:
description: 'Override agents version (empty = auto bump from pyproject.toml)'
required: false
@@ -124,11 +101,6 @@ on:
required: false
default: ''
type: string
deploy_version:
description: 'Override praisonai-deploy version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
@@ -162,35 +134,15 @@ jobs:
'pypi-auto' || 'pypi'
}}
env:
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
# GH_TOKEN is NOT set here: it is a GitHub App installation token minted in
# "Generate GitHub App token" and exported via $GITHUB_ENV, so releases are
# authored by praisonai-triage-agent[bot] rather than MervinPraison.
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
# Scope to this repo only. Without it the token is minted for every
# repository the owner has; this job never leaves PraisonAI (checkout,
# push to origin, and gh release create all target it).
repositories: ${{ github.event.repository.name }}
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
# Do not bake the token into .git/config: an http.extraheader would
# outrank the remote URL set in "Configure git" and pin this first
# (1h) token, defeating the refresh before the wrapper release.
persist-credentials: false
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -201,19 +153,10 @@ jobs:
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git config user.name "praisonai-triage-agent[bot]"
git config user.email "272766704+praisonai-triage-agent[bot]@users.noreply.github.com"
# persist-credentials:false left the remote unauthenticated; push auth
# comes from the app token embedded here (masked in logs, and the
# token dies with the runner).
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
GH_TOKEN="${APP_TOKEN}" gh auth setup-git
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Release summary
run: |
@@ -223,36 +166,6 @@ jobs:
echo "Dry run: ${{ inputs.dry_run }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
# Single source of truth for which packages this run releases. `only`
# inverts into the skip flags here; everything downstream (version
# computation, PyPI detection, publish conditions) consumes these
# outputs, so a dedicated one-package release is one input instead of
# eight skip flags.
- name: Resolve effective skips
id: flags
env:
ONLY: ${{ inputs.only }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
SKIP_DEPLOY: ${{ inputs.skip_deploy }}
SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
for pkg in agents code bot train browser mcp sandbox deploy wrapper; do
var="SKIP_$(echo "$pkg" | tr '[:lower:]' '[:upper:]')"
skip="${!var}"
if [ -n "${ONLY}" ] && [ "${ONLY}" != "all" ] && [ "${ONLY}" != "$pkg" ]; then
skip=true
fi
echo "skip_${pkg}=${skip}" >> "$GITHUB_OUTPUT"
echo "skip_${pkg}=${skip}"
done
- name: Compute release versions
id: versions
env:
@@ -265,15 +178,13 @@ jobs:
BROWSER_OVERRIDE: ${{ inputs.browser_version }}
MCP_OVERRIDE: ${{ inputs.mcp_version }}
SANDBOX_OVERRIDE: ${{ inputs.sandbox_version }}
DEPLOY_OVERRIDE: ${{ inputs.deploy_version }}
SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
run: |
python <<'PY'
import os
@@ -310,7 +221,6 @@ jobs:
browser_override = os.environ.get("BROWSER_OVERRIDE", "").strip()
mcp_override = os.environ.get("MCP_OVERRIDE", "").strip()
sandbox_override = os.environ.get("SANDBOX_OVERRIDE", "").strip()
deploy_override = os.environ.get("DEPLOY_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
validate_version("code_version", code_override)
@@ -319,7 +229,6 @@ jobs:
validate_version("browser_version", browser_override)
validate_version("mcp_version", mcp_override)
validate_version("sandbox_version", sandbox_override)
validate_version("deploy_version", deploy_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
@@ -377,14 +286,6 @@ jobs:
sys.exit(1)
current_sandbox = sandbox_match.group(1)
deploy_pyproject = root / "src/praisonai-deploy/pyproject.toml"
deploy_content = deploy_pyproject.read_text()
deploy_match = re.search(r'^version\s*=\s*"([^"]+)"', deploy_content, re.MULTILINE)
if not deploy_match:
print("Could not read praisonai-deploy version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_deploy = deploy_match.group(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
@@ -401,7 +302,6 @@ jobs:
("browser", current_browser),
("mcp", current_mcp),
("sandbox", current_sandbox),
("deploy", current_deploy),
("wrapper", current_wrapper),
):
if not SEMVER.match(value):
@@ -415,7 +315,6 @@ jobs:
skip_browser = os.environ.get("SKIP_BROWSER", "false").lower() == "true"
skip_mcp = os.environ.get("SKIP_MCP", "false").lower() == "true"
skip_sandbox = os.environ.get("SKIP_SANDBOX", "false").lower() == "true"
skip_deploy = os.environ.get("SKIP_DEPLOY", "false").lower() == "true"
if agents_override:
agents_version = agents_override
@@ -466,13 +365,6 @@ jobs:
else:
sandbox_version = bump_version(current_sandbox)
if deploy_override:
deploy_version = deploy_override
elif skip_deploy:
deploy_version = current_deploy
else:
deploy_version = bump_version(current_deploy)
if wrapper_override:
wrapper_version = wrapper_override
else:
@@ -487,7 +379,6 @@ jobs:
fh.write(f"browser_version={browser_version}\n")
fh.write(f"mcp_version={mcp_version}\n")
fh.write(f"sandbox_version={sandbox_version}\n")
fh.write(f"deploy_version={deploy_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_code={current_code}\n")
@@ -496,7 +387,6 @@ jobs:
fh.write(f"current_browser={current_browser}\n")
fh.write(f"current_mcp={current_mcp}\n")
fh.write(f"current_sandbox={current_sandbox}\n")
fh.write(f"current_deploy={current_deploy}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
@@ -506,7 +396,6 @@ jobs:
print(f"Browser: {current_browser} -> {browser_version}")
print(f"MCP: {current_mcp} -> {mcp_version}")
print(f"Sandbox: {current_sandbox} -> {sandbox_version}")
print(f"Deploy: {current_deploy} -> {deploy_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
@@ -521,17 +410,15 @@ jobs:
BROWSER_VERSION: ${{ steps.versions.outputs.browser_version }}
MCP_VERSION: ${{ steps.versions.outputs.mcp_version }}
SANDBOX_VERSION: ${{ steps.versions.outputs.sandbox_version }}
DEPLOY_VERSION: ${{ steps.versions.outputs.deploy_version }}
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
INPUT_SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
INPUT_SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
INPUT_SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
INPUT_SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
INPUT_SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
INPUT_SKIP_WRAPPER: ${{ steps.flags.outputs.skip_wrapper }}
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
INPUT_SKIP_BOT: ${{ inputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ inputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ inputs.skip_browser }}
INPUT_SKIP_MCP: ${{ inputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
skip_agents="${INPUT_SKIP_AGENTS}"
@@ -541,53 +428,30 @@ jobs:
skip_browser="${INPUT_SKIP_BROWSER}"
skip_mcp="${INPUT_SKIP_MCP}"
skip_sandbox="${INPUT_SKIP_SANDBOX}"
skip_deploy="${INPUT_SKIP_DEPLOY}"
skip_wrapper="${INPUT_SKIP_WRAPPER}"
# Returns 0 if published, 1 if definitively absent (404). Any other
# HTTP status (PyPI outage, 5xx, rate limit) FAILS the run: guessing
# "not published" would lead to a duplicate-upload failure midway
# through the release, which is strictly worse than stopping here
# before anything has been published.
pypi_published() { # $1=package $2=version
local code attempt
for attempt in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
"https://pypi.org/pypi/$1/$2/json" || echo "000")
case "$code" in
200) return 0 ;;
404) return 1 ;;
*) echo "PyPI check $1==$2 returned HTTP ${code} (attempt ${attempt}/3)"; sleep 5 ;;
esac
done
echo "❌ Cannot determine PyPI state for $1==$2; refusing to guess." >&2
exit 2
}
if [ "$skip_agents" != "true" ]; then
pypi_published praisonaiagents "${AGENTS_VERSION}" && skip_agents=true || true
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
fi
if [ "$skip_code" != "true" ]; then
pypi_published praisonai-code "${CODE_VERSION}" && skip_code=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
fi
if [ "$skip_bot" != "true" ]; then
pypi_published praisonai-bot "${BOT_VERSION}" && skip_bot=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-bot/${BOT_VERSION}/json" >/dev/null && skip_bot=true || true
fi
if [ "$skip_train" != "true" ]; then
pypi_published praisonai-train "${TRAIN_VERSION}" && skip_train=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-train/${TRAIN_VERSION}/json" >/dev/null && skip_train=true || true
fi
if [ "$skip_browser" != "true" ]; then
pypi_published praisonai-browser "${BROWSER_VERSION}" && skip_browser=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-browser/${BROWSER_VERSION}/json" >/dev/null && skip_browser=true || true
fi
if [ "$skip_mcp" != "true" ]; then
pypi_published praisonai-mcp "${MCP_VERSION}" && skip_mcp=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-mcp/${MCP_VERSION}/json" >/dev/null && skip_mcp=true || true
fi
if [ "$skip_sandbox" != "true" ]; then
pypi_published praisonai-sandbox "${SANDBOX_VERSION}" && skip_sandbox=true || true
fi
if [ "$skip_deploy" != "true" ]; then
pypi_published praisonai-deploy "${DEPLOY_VERSION}" && skip_deploy=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-sandbox/${SANDBOX_VERSION}/json" >/dev/null && skip_sandbox=true || true
fi
if [ "$skip_wrapper" != "true" ]; then
pypi_published praisonai "${WRAPPER_VERSION}" && skip_wrapper=true || true
curl -fsSL "https://pypi.org/pypi/praisonai/${WRAPPER_VERSION}/json" >/dev/null && skip_wrapper=true || true
fi
echo "skip_agents=${skip_agents}" >> "$GITHUB_OUTPUT"
echo "skip_code=${skip_code}" >> "$GITHUB_OUTPUT"
@@ -596,9 +460,8 @@ jobs:
echo "skip_browser=${skip_browser}" >> "$GITHUB_OUTPUT"
echo "skip_mcp=${skip_mcp}" >> "$GITHUB_OUTPUT"
echo "skip_sandbox=${skip_sandbox}" >> "$GITHUB_OUTPUT"
echo "skip_deploy=${skip_deploy}" >> "$GITHUB_OUTPUT"
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_deploy" = "true" ] && [ "$skip_wrapper" = "true" ]; then
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_wrapper" = "true" ]; then
echo "All target versions already on PyPI — nothing to publish."
fi
@@ -612,7 +475,6 @@ jobs:
steps.pypi_exists.outputs.skip_browser == 'true' &&
steps.pypi_exists.outputs.skip_mcp == 'true' &&
steps.pypi_exists.outputs.skip_sandbox == 'true' &&
steps.pypi_exists.outputs.skip_deploy == 'true' &&
steps.pypi_exists.outputs.skip_wrapper == 'true'
run: echo "Idempotent skip — target versions already on PyPI."
@@ -620,43 +482,34 @@ jobs:
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Only: ${{ inputs.only }}"
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Code version: ${{ steps.versions.outputs.code_version }}"
echo "Bot version: ${{ steps.versions.outputs.bot_version }}"
echo "Train version: ${{ steps.versions.outputs.train_version }}"
echo "Browser version: ${{ steps.versions.outputs.browser_version }}"
echo "MCP version: ${{ steps.versions.outputs.mcp_version }}"
echo "Sandbox version: ${{ steps.versions.outputs.sandbox_version }}"
echo "Deploy version: ${{ steps.versions.outputs.deploy_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ steps.flags.outputs.skip_agents }}" != "true" ]; then
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
fi
if [ "${{ steps.flags.outputs.skip_code }}" != "true" ]; then
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
fi
if [ "${{ steps.flags.outputs.skip_bot }}" != "true" ]; then
if [ "${{ inputs.skip_bot }}" != "true" ]; then
echo "Would publish praisonai-bot ${{ steps.versions.outputs.bot_version }}"
fi
if [ "${{ steps.flags.outputs.skip_train }}" != "true" ]; then
if [ "${{ inputs.skip_train }}" != "true" ]; then
echo "Would publish praisonai-train ${{ steps.versions.outputs.train_version }}"
fi
if [ "${{ steps.flags.outputs.skip_browser }}" != "true" ]; then
if [ "${{ inputs.skip_browser }}" != "true" ]; then
echo "Would publish praisonai-browser ${{ steps.versions.outputs.browser_version }}"
fi
if [ "${{ steps.flags.outputs.skip_mcp }}" != "true" ]; then
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
echo "Would publish praisonai-mcp ${{ steps.versions.outputs.mcp_version }}"
fi
if [ "${{ steps.flags.outputs.skip_sandbox }}" != "true" ]; then
echo "Would publish praisonai-sandbox ${{ steps.versions.outputs.sandbox_version }}"
fi
if [ "${{ steps.flags.outputs.skip_deploy }}" != "true" ]; then
echo "Would publish praisonai-deploy ${{ steps.versions.outputs.deploy_version }}"
fi
if [ "${{ steps.flags.outputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }} --sandbox-pin ${{ steps.versions.outputs.sandbox_version }} --deploy-pin ${{ steps.versions.outputs.deploy_version }}"
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
@@ -684,9 +537,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonaiagents on PyPI
if: >-
@@ -715,6 +566,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit agents version bump
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-code
if: >-
inputs.dry_run != true &&
@@ -743,9 +607,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-code on PyPI
if: >-
@@ -774,6 +636,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-code version bump
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
run: |
set -euo pipefail
git add src/praisonai-code/pyproject.toml src/praisonai-code/uv.lock src/praisonai-code/praisonai_code/__init__.py
git diff --cached --quiet && { echo "No code files to commit"; exit 0; }
git commit -m "Bump praisonai-code to ${{ steps.versions.outputs.code_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-bot
if: >-
inputs.dry_run != true &&
@@ -802,9 +677,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-bot on PyPI
if: >-
@@ -833,6 +706,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-bot version bump
if: >-
inputs.dry_run != true &&
inputs.skip_bot != true &&
steps.pypi_exists.outputs.skip_bot != 'true'
run: |
set -euo pipefail
git add src/praisonai-bot/pyproject.toml src/praisonai-bot/uv.lock src/praisonai-bot/praisonai_bot/_version.py
git diff --cached --quiet && { echo "No bot files to commit"; exit 0; }
git commit -m "Bump praisonai-bot to ${{ steps.versions.outputs.bot_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-train
if: >-
inputs.dry_run != true &&
@@ -861,9 +747,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-train on PyPI
if: >-
@@ -892,6 +776,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-train version bump
if: >-
inputs.dry_run != true &&
inputs.skip_train != true &&
steps.pypi_exists.outputs.skip_train != 'true'
run: |
set -euo pipefail
git add src/praisonai-train/pyproject.toml src/praisonai-train/uv.lock src/praisonai-train/praisonai_train/_version.py
git diff --cached --quiet && { echo "No train files to commit"; exit 0; }
git commit -m "Bump praisonai-train to ${{ steps.versions.outputs.train_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-browser
if: >-
inputs.dry_run != true &&
@@ -920,9 +817,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-browser on PyPI
if: >-
@@ -951,6 +846,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-browser version bump
if: >-
inputs.dry_run != true &&
inputs.skip_browser != true &&
steps.pypi_exists.outputs.skip_browser != 'true'
run: |
set -euo pipefail
git add src/praisonai-browser/pyproject.toml src/praisonai-browser/uv.lock src/praisonai-browser/praisonai_browser/_version.py
git diff --cached --quiet && { echo "No browser files to commit"; exit 0; }
git commit -m "Bump praisonai-browser to ${{ steps.versions.outputs.browser_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-mcp
if: >-
inputs.dry_run != true &&
@@ -979,9 +887,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-mcp on PyPI
if: >-
@@ -1010,6 +916,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-mcp version bump
if: >-
inputs.dry_run != true &&
inputs.skip_mcp != true &&
steps.pypi_exists.outputs.skip_mcp != 'true'
run: |
set -euo pipefail
git add src/praisonai-mcp/pyproject.toml src/praisonai-mcp/uv.lock src/praisonai-mcp/praisonai_mcp/_version.py
git diff --cached --quiet && { echo "No mcp files to commit"; exit 0; }
git commit -m "Bump praisonai-mcp to ${{ steps.versions.outputs.mcp_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-sandbox
if: >-
inputs.dry_run != true &&
@@ -1038,9 +957,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-sandbox on PyPI
if: >-
@@ -1069,101 +986,20 @@ jobs:
sleep "$INTERVAL"
done
- name: Publish praisonai-deploy
- name: Commit praisonai-sandbox version bump
if: >-
inputs.dry_run != true &&
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
working-directory: src/praisonai-deploy
env:
NEW_VERSION: ${{ steps.versions.outputs.deploy_version }}
CURRENT: ${{ steps.versions.outputs.current_deploy }}
inputs.skip_sandbox != true &&
steps.pypi_exists.outputs.skip_sandbox != 'true'
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
new = os.environ["NEW_VERSION"]
current = os.environ["CURRENT"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("praisonai_deploy/_version.py")
version_content = version_py.read_text()
version_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', version_content, count=1))
PY
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-deploy on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
run: |
set -euo pipefail
PACKAGE="praisonai-deploy"
VERSION="${{ steps.versions.outputs.deploy_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
# All per-package version bumps are still uncommitted at this point: the
# wrapper release below stages every one of them (bump_and_release.py's
# release_files list) into a SINGLE "Release v…" commit.
# App tokens expire after 1h and this job can exceed that (9 packages ×
# up to a 600s PyPI wait). Re-mint so the final commit/tag/push/release
# never runs on an expired token.
- name: Refresh GitHub App token
id: app-token-final
if: ${{ !cancelled() && inputs.dry_run != true }}
# A failed re-mint must not fail the release: the original token may
# still be valid, and the next step only swaps tokens on success.
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
# Guarded on the refresh actually succeeding: if the re-mint failed
# (API blip), the ORIGINAL token — still wired into the remote URL and
# GH_TOKEN — may well be valid, and overwriting it with an empty string
# would turn a recoverable situation into a guaranteed auth failure.
- name: Use refreshed GitHub App token
if: ${{ !cancelled() && inputs.dry_run != true && steps.app-token-final.outcome == 'success' }}
env:
APP_TOKEN: ${{ steps.app-token-final.outputs.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
git add src/praisonai-sandbox/pyproject.toml src/praisonai-sandbox/uv.lock src/praisonai-sandbox/praisonai_sandbox/_version.py
git diff --cached --quiet && { echo "No sandbox files to commit"; exit 0; }
git commit -m "Bump praisonai-sandbox to ${{ steps.versions.outputs.sandbox_version }}"
git pull --rebase origin main
git push origin main
- name: Bump and release wrapper
id: wrapper
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
@@ -1178,8 +1014,7 @@ jobs:
--browser-pin "${{ steps.versions.outputs.browser_version }}" \
--mcp-pin "${{ steps.versions.outputs.mcp_version }}" \
--sandbox-pin "${{ steps.versions.outputs.sandbox_version }}" \
--deploy-pin "${{ steps.versions.outputs.deploy_version }}" \
--wait --max-wait 600 --no-add-all --force
--wait --max-wait 600 --no-add-all
- name: Publish praisonai wrapper to PyPI
if: >-
@@ -1187,7 +1022,7 @@ jobs:
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
working-directory: src/praisonai
run: uv publish --check-url https://pypi.org/simple/
run: uv publish
- name: Wait for praisonai on PyPI
if: >-
@@ -1216,139 +1051,47 @@ jobs:
sleep "$INTERVAL"
done
# Actually probes PyPI — previously this step only echoed checkmarks
# from the input flags, so it "verified" publishes that never happened.
- name: Verify PyPI versions
if: inputs.dry_run != true
run: |
set -euo pipefail
verify() { # $1=pypi-name $2=version $3=effective-skip
if [ "$3" = "true" ]; then
echo "⏭️ $1 skipped"
return 0
fi
if curl -fsSL --max-time 30 "https://pypi.org/pypi/$1/$2/json" >/dev/null; then
echo "✅ $1==$2"
else
echo "❌ $1==$2 NOT found on PyPI"
exit 1
fi
}
verify praisonaiagents "${{ steps.versions.outputs.agents_version }}" "${{ steps.flags.outputs.skip_agents }}"
verify praisonai-code "${{ steps.versions.outputs.code_version }}" "${{ steps.flags.outputs.skip_code }}"
verify praisonai-bot "${{ steps.versions.outputs.bot_version }}" "${{ steps.flags.outputs.skip_bot }}"
verify praisonai-train "${{ steps.versions.outputs.train_version }}" "${{ steps.flags.outputs.skip_train }}"
verify praisonai-browser "${{ steps.versions.outputs.browser_version }}" "${{ steps.flags.outputs.skip_browser }}"
verify praisonai-mcp "${{ steps.versions.outputs.mcp_version }}" "${{ steps.flags.outputs.skip_mcp }}"
verify praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}" "${{ steps.flags.outputs.skip_sandbox }}"
verify praisonai-deploy "${{ steps.versions.outputs.deploy_version }}" "${{ steps.flags.outputs.skip_deploy }}"
verify praisonai "${{ steps.versions.outputs.wrapper_version }}" "${{ steps.flags.outputs.skip_wrapper }}"
AGENTS="${{ steps.versions.outputs.agents_version }}"
CODE="${{ steps.versions.outputs.code_version }}"
BOT="${{ steps.versions.outputs.bot_version }}"
TRAIN="${{ steps.versions.outputs.train_version }}"
BROWSER="${{ steps.versions.outputs.browser_version }}"
MCP="${{ steps.versions.outputs.mcp_version }}"
SANDBOX="${{ steps.versions.outputs.sandbox_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
# Safety net. On a clean run the wrapper's "Release v…" commit already
# carries every bump and this is a no-op. It only fires when the single
# commit could not happen: a package publish failed midway (those versions
# are already live on PyPI, so the bumps must not be lost), skip_wrapper
# was set, or the wrapper itself failed.
- name: Persist any uncommitted version bumps
if: ${{ !cancelled() && inputs.dry_run != true }}
run: |
set -euo pipefail
# If the wrapper release did not complete, drop any uncommitted
# wrapper-side rewrites (version.py, Dockerfiles, pins): committing
# them would put a wrapper version on main that never reached PyPI,
# and the next auto-bump would skip past it forever. Committed
# changes are untouched (checkout only reverts the working tree),
# and if the wrapper was skipped these files were never modified.
if [ "${{ steps.wrapper.outcome }}" != "success" ]; then
git checkout -- \
src/praisonai/praisonai/version.py \
src/praisonai/pyproject.toml \
src/praisonai/uv.lock \
src/praisonai/README.md \
src/praisonai/praisonai.rb \
src/praisonai-deploy/praisonai_deploy/docker.py \
docker/ 2>/dev/null || true
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "✅ praisonaiagents==${AGENTS}"
fi
# -u = tracked modifications only; uv build output under dist/ is
# untracked and stays out of the commit.
git add -u
git diff --cached --quiet && { echo "Nothing left to commit"; exit 0; }
# Name the packages actually staged, so a single-package release
# (every other skip_* set) reads "bump praisonai-mcp" rather than a
# generic message. Falls back when nothing under src/ is staged.
PKGS="$(git diff --cached --name-only \
| sed -n 's#^src/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ *$//')"
if [ -n "$PKGS" ]; then
MSG="chore(release): bump ${PKGS} [skip ci]"
else
MSG="chore(release): persist version bumps [skip ci]"
fi
echo "Commit message: ${MSG}"
git commit -m "$MSG"
# This step fires exactly when the release went sideways — i.e. when
# main is most likely to have moved — so the rebase+push retries.
# Losing this commit deadlocks future auto-releases: the packages
# are on PyPI at N+1 while the repo says N, so every later run
# detects "already published" and skips both publish and bump.
for attempt in 1 2 3; do
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
# During rebase, "theirs" is the commit being replayed — the
# version bumps — so they win any conflict.
if ! git rebase -X theirs origin/main; then
git rebase --abort || true
echo "Rebase attempt ${attempt}/3 failed"
sleep 5
continue
fi
fi
if git push origin main; then
echo "Pushed version bumps (attempt ${attempt}/3)"
exit 0
fi
echo "Push attempt ${attempt}/3 rejected; retrying..."
sleep 5
done
echo "❌ Could not push version bumps after 3 attempts."
echo " Repo is now BEHIND PyPI; commit the version files on main manually"
echo " or future auto-releases will no-op ('already published')."
exit 1
# Dedicated releases (skip_wrapper) create no "Release v…" tag — the
# wrapper drives that — so tag each published package individually for
# provenance. Full releases keep their single v… tag unchanged.
- name: Tag dedicated package releases
if: >-
${{ !cancelled() && inputs.dry_run != true &&
steps.flags.outputs.skip_wrapper == 'true' &&
steps.versions.outcome == 'success' }}
run: |
set -euo pipefail
tag_pkg() { # $1=effective-skip $2=pypi-name $3=version
if [ "$1" = "true" ]; then
return 0
fi
local tag="$2-v$3"
# Only tag what is confirmed on PyPI — a package whose publish
# failed must not get a tag claiming it shipped.
if ! curl -fsSL --max-time 30 "https://pypi.org/pypi/$2/$3/json" >/dev/null; then
echo "⏭️ $2==$3 not on PyPI; not tagging"
return 0
fi
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "tag ${tag} already exists"
return 0
fi
git tag "${tag}"
git push origin "refs/tags/${tag}"
echo "🏷️ ${tag}"
}
tag_pkg "${{ steps.flags.outputs.skip_agents }}" praisonaiagents "${{ steps.versions.outputs.agents_version }}"
tag_pkg "${{ steps.flags.outputs.skip_code }}" praisonai-code "${{ steps.versions.outputs.code_version }}"
tag_pkg "${{ steps.flags.outputs.skip_bot }}" praisonai-bot "${{ steps.versions.outputs.bot_version }}"
tag_pkg "${{ steps.flags.outputs.skip_train }}" praisonai-train "${{ steps.versions.outputs.train_version }}"
tag_pkg "${{ steps.flags.outputs.skip_browser }}" praisonai-browser "${{ steps.versions.outputs.browser_version }}"
tag_pkg "${{ steps.flags.outputs.skip_mcp }}" praisonai-mcp "${{ steps.versions.outputs.mcp_version }}"
tag_pkg "${{ steps.flags.outputs.skip_sandbox }}" praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}"
tag_pkg "${{ steps.flags.outputs.skip_deploy }}" praisonai-deploy "${{ steps.versions.outputs.deploy_version }}"
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "✅ praisonai-code==${CODE}"
fi
if [ "${{ inputs.skip_bot }}" != "true" ]; then
echo "✅ praisonai-bot==${BOT}"
fi
if [ "${{ inputs.skip_train }}" != "true" ]; then
echo "✅ praisonai-train==${TRAIN}"
fi
if [ "${{ inputs.skip_browser }}" != "true" ]; then
echo "✅ praisonai-browser==${BROWSER}"
fi
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
echo "✅ praisonai-mcp==${MCP}"
fi
if [ "${{ inputs.skip_sandbox }}" != "true" ]; then
echo "✅ praisonai-sandbox==${SANDBOX}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "✅ praisonai==${WRAPPER}"
fi
@@ -24,7 +24,6 @@ jobs:
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
@@ -35,7 +34,4 @@ jobs:
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"
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, GH_TOKEN → $TARGET"
-106
View File
@@ -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})."
+5 -40
View File
@@ -5,9 +5,6 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -15,9 +12,6 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -101,9 +95,8 @@ jobs:
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
- shard: train
workdir: src/praisonai-train
# tests/unit/data/ (96 tests) was collected by no workflow at all.
paths: >-
tests/unit/train/ tests/unit/data/
tests/unit/train/
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
@@ -121,17 +114,11 @@ jobs:
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: deploy
workdir: src/praisonai-deploy
paths: >-
tests/
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: subdirs
paths: >-
tests/unit/scheduler/
tests/unit/integrations/
tests/unit/deploy/
tests/unit/mcp/
tests/unit/knowledge/
tests/unit/llm/
@@ -196,9 +183,6 @@ jobs:
if [ "$WORKDIR" = "src/praisonai-sandbox" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-deploy" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-mcp" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c12_mcp_imports.sh"
fi
@@ -284,27 +268,9 @@ jobs:
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py
helm-charts:
name: helm-charts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Helm
uses: azure/setup-helm@v3
with:
version: v3.14.4
- name: Lint and template Helm charts
shell: bash
run: |
set -euo pipefail
bash "$GITHUB_WORKSPACE/scripts/check_helm_charts.sh"
test-core:
name: test-core
needs: [test-core-collect, test-core-unit, test-core-integration, helm-charts]
needs: [test-core-collect, test-core-unit, test-core-integration]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -316,9 +282,8 @@ jobs:
collect='${{ needs.test-core-collect.result }}'
unit='${{ needs.test-core-unit.result }}'
integration='${{ needs.test-core-integration.result }}'
helm='${{ needs.helm-charts.result }}'
echo "collect=$collect unit=$unit integration=$integration helm=$helm"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ] || [ "$helm" != "success" ]; then
echo "collect=$collect unit=$unit integration=$integration"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ]; then
echo "Core Tests failed"
exit 1
fi
+2 -153
View File
@@ -86,7 +86,6 @@ jobs:
tests/unit/test_c11_browser_backward_compat.py \
tests/unit/test_c12_mcp_backward_compat.py \
tests/unit/test_c13_sandbox_backward_compat.py \
tests/unit/test_c14_deploy_backward_compat.py \
-q --tb=line --timeout=30 --maxfail=5
python -m pytest tests/unit/cli/ \
../praisonai-bot/tests/unit/cli/test_onboard_command.py \
@@ -98,20 +97,6 @@ jobs:
--ignore=tests/unit/cli/test_message_queue.py \
--maxfail=5
- name: Run praisonai-agents subscription-auth smoke tests
env:
PRAISONAI_TEST_TIER: smoke
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
OPENAI_API_KEY: 'test-key'
shell: bash
run: |
set -euo pipefail
cd src/praisonai-agents
python -m pytest tests/smoke/test_subscription_auth.py \
tests/unit/auth/test_agent_auth_wiring.py \
-q --tb=line --timeout=60 --maxfail=5
- name: Standalone praisonai-code import smoke
shell: bash
run: |
@@ -284,26 +269,10 @@ jobs:
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
python -m pytest src/praisonai-sandbox/tests -q -m "not network"
- name: Standalone praisonai-deploy smoke (C14)
run: |
set -euo pipefail
python -m venv /tmp/praisonai-deploy-standalone
/tmp/praisonai-deploy-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-deploy
/tmp/praisonai-deploy-standalone/bin/python -c "
from praisonai_deploy._version import __version__ as pkg_version
import praisonai_deploy
assert praisonai_deploy.__version__ == pkg_version
assert 'main' not in __import__('sys').modules
print('deploy standalone ok', praisonai_deploy.__version__)
"
/tmp/praisonai-deploy-standalone/bin/praisonai-deploy --help
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
python -m pytest src/praisonai-deploy/tests -q -m "not network"
- name: Audit hybrid module imports (C8 full stack)
run: |
set -euo pipefail
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai-deploy -e src/praisonai
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai
python scripts/audit_hybrid_modules.py
- name: Smoke Test Summary
@@ -759,126 +728,6 @@ jobs:
if: steps.check-key.outputs.has_key == 'false'
run: echo "✅ Cohere tests skipped (no API key available)"
# ============================================
# OLLAMA LIVE TESTS - Real local model, no API key
# Nightly / extended only. Installs Ollama on the runner and pulls a small
# tools-capable model. Unlike the other live jobs this one needs no secret,
# so its required step has no "|| echo" fallback: a failure here is a real
# regression in local-model tool calling, not a missing credential.
# ============================================
extended-ollama:
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Bump deliberately: this is the version everything in this job was
# verified against. Latest at time of writing is v0.33.3.
OLLAMA_VERSION: v0.33.2
# 522 MB, capabilities: completion, tools, thinking.
OLLAMA_TEST_MODEL: qwen3:0.6b
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-ollama-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Cache Ollama model blobs
uses: actions/cache@v4
with:
path: ~/.ollama/models
# No restore-keys on purpose: a partial hit from a different model would
# silently test something other than OLLAMA_TEST_MODEL.
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
- name: Install Ollama
shell: bash
run: |
set -euo pipefail
# The official installer, but VERSION-PINNED via OLLAMA_VERSION (which it
# honours) so this job cannot silently change what it tests. Fetching the
# release asset directly is not a workable alternative: since v0.33 the
# linux bundle ships as a 1.4 GB .tar.zst with GPU runtimes bundled in,
# and the installer already handles the zst/tgz split across versions.
sudo apt-get update -qq && sudo apt-get install -y -qq zstd
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
- name: Start Ollama and pull model
shell: bash
run: |
set -euo pipefail
ollama serve > /tmp/ollama-serve.log 2>&1 &
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
echo "Ollama is up after ${i}s"
break
fi
sleep 1
done
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
ollama pull "$OLLAMA_TEST_MODEL"
ollama list
- name: Install dependencies
run: |
pip install --upgrade pip
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
- name: Run Ollama minimum contract (required)
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
shell: bash
run: |
set -euo pipefail
cd src/praisonai
# No "|| echo" here: no secret is involved, so a failure is a real
# regression rather than a missing credential.
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short \
--timeout=120
- name: Run remaining Ollama live tests (informational)
if: always()
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
run: |
cd src/praisonai
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short \
--timeout=180 \
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
- name: Ollama server log
if: failure()
run: tail -100 /tmp/ollama-serve.log
# ============================================
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
# Nightly / extended tier only (~401 tests)
@@ -956,7 +805,7 @@ jobs:
# TEST SUMMARY
# ============================================
test-summary:
needs: [smoke, main, extended-ollama]
needs: [smoke, main]
if: always()
runs-on: ubuntu-latest
+1 -5
View File
@@ -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
+7 -50
View File
@@ -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 -7
View File
@@ -12,7 +12,6 @@ assets
.DS_Store
.pytest_cache
praisonAI.egg-info
src/*/build/
flagged
test.yaml
db
@@ -25,8 +24,6 @@ output
!src/praisonai-agents/praisonaiagents/output/
!src/praisonai-code/praisonai_code/cli/output/
!src/praisonai-bot/praisonai_bot/cli/output/
!src/praisonai-ts/src/db/
!src/praisonai-ts/src/cli/output/
.files
threads.db
@@ -115,7 +112,4 @@ Cargo.lock
src/praisonai/build*.egg-info/
src/praisonai/PraisonAI.egg-info/
jobs/*
*.egg-info
# Local Android SDK/NDK/JDK for building praisonai-mobile on a laptop without
# Android Studio (how: .github/workflows/mobile-platforms.yml). Several GB.
.android-sdk/
*.egg-info
+3 -16
View File
@@ -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 eight 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/`, 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)
- 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)
- 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.
+22 -41
View File
@@ -1,6 +1,6 @@
# PraisonAI Architecture
> **Last updated:** 2026-07-31 (C14 — nine-package tiered model)
> **Last updated:** 2026-07-17 (C13 — eight-package tiered model)
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers **Python tiered package model (C7.1 + C9 + C10)**, system design, runtime
@@ -11,7 +11,7 @@
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13--c14)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13)
3. [System Overview](#3-system-overview)
4. [Layered Architecture](#4-layered-architecture)
5. [Core Data Contracts](#5-core-data-contracts)
@@ -51,18 +51,17 @@ Orchestration + Observability** core to unlock adoption and trust:
---
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai-deploy` · `praisonai`
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai`
The Python monorepo publishes nine packages in three tiers with strict dependency
The Python monorepo publishes eight packages in three tiers with strict dependency
direction. C7 delivered a standalone agentic hot path; C7.1 formalised
code/wrapper ownership; C9 extracted bots, gateway, and channel CLI into
`praisonai-bot`; C10 extracted LLM fine-tuning and agent training into
`praisonai-train`; C11 extracted browser automation into `praisonai-browser`;
C12 extracted the heavy MCP host into `praisonai-mcp`.
C13 extracted sandbox backends into `praisonai-sandbox`.
C14 extracted deployment (API, Docker, cloud) into `praisonai-deploy`.
```mermaid
flowchart TB
@@ -70,14 +69,13 @@ flowchart TB
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
end
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox + Deploy]
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox]
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM"]
Bot["praisonai-bot<br/>bots, gateway, channel CLI, OS daemon"]
Train["praisonai-train<br/>LLM fine-tuning, agent training"]
Browser["praisonai-browser<br/>extension bridge, CDP, Playwright"]
MCP["praisonai-mcp<br/>MCP server host, auth, capability adapters"]
Sandbox["praisonai-sandbox<br/>Docker, E2B, Modal, Sandlock backends"]
Deploy["praisonai-deploy<br/>API, Docker, AWS/Azure/GCP deploy"]
end
subgraph tier3 [Tier 3 — Wrapper]
@@ -90,53 +88,36 @@ flowchart TB
Agents --> Browser
Agents --> MCP
Agents --> Sandbox
Agents --> Deploy
Agents --> Wrapper
Code -.->|"lazy _bot_bridge"| Bot
Code -.->|"lazy _train_bridge"| Train
Code -.->|"lazy _browser_bridge"| Browser
Code -.->|"lazy _mcp_bridge"| MCP
Code -.->|"lazy _sandbox_bridge"| Sandbox
Code -.->|"lazy _deploy_bridge"| Deploy
Code -.->|"lazy _wrapper_bridge"| Wrapper
Bot -.->|"lazy _code_bridge / _wrapper_bridge"| Code
Bot -.->|"lazy _wrapper_bridge"| Wrapper
Train -.->|"lazy _code_bridge / _wrapper_bridge"| Code
```
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` + `praisonai-deploy` → `praisonai`
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` → `praisonai`
**Backward compatibility:** `praisonai.bots`, `praisonai.gateway`, `praisonai.train`,
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, `praisonai.deploy`, and related CLI paths remain as
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, and related CLI paths remain as
`alias_package` shims to `praisonai_bot.*` / `praisonai_train.*` /
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*` / `praisonai_deploy.*`.
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*`.
| Tier | Package | Owns | Must not depend on |
|------|---------|------|-------------------|
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox`, `praisonai-deploy` |
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox` |
| 2a | `src/praisonai-code/` | `run`/`chat`/`code`, Typer, runtime, LLM, tool resolution | **`praisonai` as a PyPI dependency** (optional lazy imports via `_wrapper_bridge` only) |
| 2b | `src/praisonai-bot/` | Bots, gateway, channel CLI, OS daemon, gateway scheduler tick | **`praisonai` as a PyPI dependency** (optional lazy `_wrapper_bridge` for jobs/UI) |
| 2c | `src/praisonai-train/` | LLM fine-tuning (Unsloth), agent training, `train` CLI, conda env setup | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for legacy dispatch) |
| 2d | `src/praisonai-browser/` | Extension bridge, CDP/hybrid automation, `browser` CLI | **`praisonai` as a PyPI dependency** (none required; depends on `praisonaiagents` only) |
| 2e | `src/praisonai-mcp/` | MCP server host, auth, transports, `mcp` CLI | **`praisonai` as a PyPI dependency** (lazy `_wrapper_bridge` for full capability registry) |
| 2f | `src/praisonai-sandbox/` | Sandbox backends (Docker, E2B, Modal, Sandlock, SSH), `SandboxRegistry` | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for `PluginRegistry`) |
| 2g | `src/praisonai-deploy/` | Deploy API/Docker/cloud, `deploy` CLI, scheduler integration | **`praisonai` as a PyPI dependency** (lazy `_plugin_registry` via `_code_bridge`) |
| 3 | `src/praisonai/` | `framework_adapters/`, serve, dashboard, async jobs API | — |
### Repo infra (not PyPI)
Deployment packaging that stays in the git checkout, not in any tier-2 wheel:
| Path | Runtime owner | Orchestration |
|------|---------------|---------------|
| `src/praisonai-bot/infra/helm/praisonai-gateway/` | `praisonai-bot` (gateway) | C14 `deploy helm` wrapper |
| `src/praisonai-deploy/infra/helm/praisonai-agents-api/` | Generated API from C14 | C14 cross-link |
| `src/praisonai-deploy/infra/compose/agents-stack/` | Docker Compose | `praisonai deploy compose up/down` |
| `src/praisonai-deploy/infra/starters/` | Starter scaffolds | `praisonai deploy create --template` |
| `docker/` | Mixed (wrapper/bot dev stacks) | Not C14 — see bot manifest for `docker/bots/` |
Same boundary as C14: Helm/K8s manifests are **repo infra**, not `pip install praisonai-deploy` content.
**Config kernel:** Phase 0 `praisonai/common/` was skipped; shared config lives in
`praisonai_code/cli/configuration/` and is reached by the bot tier via lazy
`_code_bridge` (see `src/praisonai/tests/CONFIG_KERNEL.md`).
@@ -222,15 +203,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 +224,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/`)
---
-12
View File
@@ -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`
+24 -293
View File
@@ -35,25 +35,16 @@ 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>
&nbsp;&nbsp;
<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>
&nbsp;&nbsp;
<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">
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
</p>
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
```
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
@@ -65,6 +56,12 @@ curl -fsSL https://praison.ai/install.sh | bash
pip install praisonai
```
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
<div align="center">
<a href="https://docs.praison.ai">
<p align="center">
@@ -109,232 +106,6 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
---
## 🧬 The Five-Layer Agent Stack
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
```
┌─────────────────────────────────────────────────────────────────┐
│ ⬡ MANAGED AGENTS — Where does it actually run? │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 5 · GRAPH — Who runs when, and who checks whom? │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 4 · LOOP — When do we stop? │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ 3 · HARNESS — Can it act, and be checked? │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ 2 · CONTEXT — Is the right thing in the window? │ │ │ │ │
│ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ 1 · PROMPT — Did I say it clearly? │ │ │ │ │ │
│ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
| Layer | The question it answers | PraisonAI |
|:--|:--|:--|
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
### Layer 1 · Prompt — *Did I say it clearly?*
Role, instructions, examples, output format.
```python
from praisonaiagents import Agent
agent = Agent(
role="Senior Data Analyst",
goal="Turn raw numbers into decisions",
output="verbose", # markdown-formatted output
)
agent.start("Summarise Q3 revenue trends")
```
### Layer 2 · Context — *Is the right thing in the window?*
Write, select, compress, isolate — the four context operations, one parameter each.
```python
from praisonaiagents import Agent
agent = Agent(
instructions="You are a support engineer.",
memory={"user_id": "u-42"}, # write — persists across runs (needs a user_id)
knowledge=["docs/"], # select — retrieves only what's relevant
context="summarize", # compress — auto-compacts before the limit
)
```
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
### Layer 3 · Harness — *Can it act, and be checked?*
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
```python
from praisonaiagents import Agent, MCP, tool
@tool
def deploy(env: str) -> str:
"""Deploy the current build to an environment."""
return f"Deployed to {env}"
agent = Agent(
name="ReleaseEngineer",
instructions="You are a release engineer.",
tools=[deploy, MCP("npx -y @modelcontextprotocol/server-filesystem /tmp")],
approval=True, # guide — human gate before risky tools run
)
agent.start("Deploy to staging, then list the files you can read")
```
### Layer 4 · Loop — *When do we stop?*
Hard iteration caps, budget ceilings, no-progress detection and completion checks — every brake is explicit.
```python
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
instructions="Fix the failing tests.",
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
autonomy=True, # required to drive the loop with run_autonomous()
)
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
print(result.completion_reason)
# goal | no_tool_calls | max_iterations | timeout | doom_loop | needs_help | error
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
# surfaced here as completion_reason="error")
```
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
### Layer 5 · Graph — *Who runs when, and who checks whom?*
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
```python
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import route, parallel, repeat
flow = AgentFlow(steps=[
classifier,
route({"bug": [bug_agent], "feature": [feature_agent], "default": [triage]}),
parallel([reviewer, tester]), # fan out, join automatically
repeat(editor, until=lambda ctx: "approved" in ctx.previous_result.lower(),
max_iterations=3), # evaluator–optimizer
])
flow.run("Ticket #123: login fails on Safari")
```
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
```bash
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
```python
from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
steps:
- agent: writer
action: "Write 'hello' to /workspace/note.txt"
- agent: reader
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
```python
from praisonaiagents import Agent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
praisonai managed ps # list running sandboxes
praisonai managed stop --all # reclaim them
```
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
---
## 🌌 The PraisonAI Ecosystem
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
@@ -429,9 +200,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| 🧠 | **Planning Mode** — plan → execute → reason | `planning=True` |
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://docs.praison.ai/docs/agents/deep-research) |
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://docs.praison.ai/docs/code/external-agents) |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoff=True` |
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
| | **Web Search + Fetch** — native browsing | `web=True` |
| | **Web Search + Fetch** — native browsing | `web_search=True` |
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://docs.praison.ai/docs/concepts/reflection) |
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://docs.praison.ai/docs/concepts/agentflow) |
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
@@ -441,9 +212,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| | Feature | How |
|--|---------|-----|
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
| 💡 | **Prompt Caching** — reduce latency + cost | `prompt_caching=True` |
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
| 💭 | **Thinking Budgets** — control reasoning depth | `agent.thinking_budget = 1024` |
| 💭 | **Thinking Budgets** — control reasoning depth | `thinking_budget=1024` |
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://docs.praison.ai/docs/concepts/rag) |
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://docs.praison.ai/docs/features/model-router) |
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://docs.praison.ai/docs/features/checkpoints) |
@@ -570,10 +341,8 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
)
agent.chat("Hello!") # Auto-persists messages, runs, traces
```
@@ -1019,16 +788,6 @@ PraisonAI is built for speed, with agent instantiation in around 14μs. This red
---
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
## 🔍 Langfuse Tracing
```bash
@@ -1112,39 +871,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>
@@ -1158,10 +891,8 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
)
```
@@ -1177,8 +908,8 @@ from praisonaiagents import Agent
agent = Agent(
name="Assistant",
# Enables file-based memory (no extra deps!)
memory={"user_id": "user123"},
memory=True, # Enables file-based memory (no extra deps!)
user_id="user123"
)
```
+1 -8
View File
@@ -8,18 +8,11 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
### Packages maintained in their own repositories
`praisonai-platform` now lives at
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
Report platform vulnerabilities through
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
not here — it is versioned and released independently of the SDK.
## In scope
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
+100 -103
View File
@@ -7,7 +7,7 @@ Regenerate with: `praisonai docs api-md --write`
Types:
```python
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
```
Methods:
@@ -16,6 +16,8 @@ Methods:
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_dict</a>(data: Dict[str, Any]) -> 'HandoffConfig'</code>
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -23,21 +25,24 @@ Methods:
Types:
```python
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PraisonAIAgents, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
```
Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
@@ -68,12 +73,9 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>(value: Optional[str]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -81,7 +83,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str = '', threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False, goal: Optional[str] = None, goal_criteria: Optional[Any] = None, judge_model: Optional[str] = None) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">set_snapshot_root</a>(project_path: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', action: str = 'add', **kwargs: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>() -> Optional[Any]</code>
@@ -90,7 +91,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value: Optional[int]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
* <code title="class AutoRagAgent">AutoRagAgent.<a href="./src/praisonai-agents/praisonaiagents/agents/auto_rag_agent.py">achat</a>(message: str, **kwargs) -> str</code>
@@ -179,15 +179,14 @@ Methods:
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">run</a>(**kwargs) -> Any</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_plugins</a>() -> int</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
@@ -242,12 +241,10 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
@@ -255,13 +252,9 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memory</a>(memory_id: str, memory_type: Optional[str] = None) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_short_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_context</a>(query: Optional[str] = None, **kwargs) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">remember</a>(content: str, **kwargs) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
@@ -269,8 +262,8 @@ 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, **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_long_term</a>(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, **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, **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>
@@ -309,7 +302,7 @@ Methods:
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">normalize_content</a>(content)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">reset</a>()</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">search</a>(query, user_id = None, agent_id = None, run_id = None, rerank = None, **kwargs)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None, is_content = False)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">update</a>(memory_id, data)</code>
* <code title="class KnowledgeConfig">KnowledgeConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -387,7 +380,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
@@ -471,7 +463,7 @@ Methods:
Types:
```python
from praisonaiagents import SkillLoader, SkillsConfig
from praisonaiagents import SkillLoader, SkillManager, SkillMetadata, SkillProperties, SkillsConfig
```
Methods:
@@ -483,6 +475,54 @@ Methods:
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_metadata</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_references</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_scripts</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_bundle</a>(manifest: 'BundleManifest') -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill_by_name</a>(name: str, skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundle_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundles</a>() -> List['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None, base_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover_bundles</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle</a>(name: str) -> Optional['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle_instructions</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
# Session
@@ -528,7 +568,6 @@ Methods:
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_prompts</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_resources</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_tools</a>() -> List[Callable]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">list_active_server_names</a>() -> set</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">shutdown</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">to_openai_tool</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">with_tool_prefix</a>(prefix: str) -> 'MCP'</code>
@@ -692,7 +731,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, RunOutcome, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -715,7 +754,6 @@ Methods:
* <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">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>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">get_app</a>() -> Any</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">serve</a>(host: Optional[str] = None, port: Optional[int] = None, reload: bool = False, **kwargs: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aannounce_completion</a>(agent_id: str, task_id: str, result: Any, success: bool = True, error: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> None</code>
@@ -727,7 +765,6 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aspawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">await_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">clean_json_output</a>(output: str) -> str</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
@@ -752,28 +789,22 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <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) -> 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>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
@@ -782,7 +813,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, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
```
# CLI
@@ -850,13 +881,11 @@ Methods:
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed ps <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed stop <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
@@ -886,7 +915,6 @@ Methods:
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
@@ -903,12 +931,7 @@ Methods:
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule run <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -917,7 +940,10 @@ Methods:
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise init <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise report <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
@@ -926,23 +952,17 @@ Methods:
Types/Exports:
```ts
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createVideoAgent, createVisionAgent } from "./agent";
export type { AgentChatCallOptions, AgentChatOptions, AgentEvent, AgentExecuteTask, AgentGuardrailEntry, AgentGuardrailFunction, AgentGuardrailInput, AgentHooksInput, AgentMemoryStore, AgentMessage, AgentRetryConfig, AgentStreamOptions, AgentTaskLike, AgentTeamConfig, AgentTeamProcess, AgentTeamStartDictOptions, AgentTeamStartOptions, AgentTeamStartOptionsInput, AgentWebConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason, TaskCallback, TaskGuardrail, TaskOnError } from "./agent";
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
export type { AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
export { AudioAgent, createAudioAgent } from "./agent/audio";
export type { AudioAgentConfig, AudioConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
export { ContextAgent, createContextAgent, create_context_agent } from "./agent/context";
export { ContextPolicy, DEFAULT_HANDOFF_TOOL_POLICY, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, HandoffToolPolicyMode, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, handoffToolName, handoff_filters, listAgentTools, parallelHandoffs, parallel_handoffs, promptWithHandoffInstructions, prompt_with_handoff_instructions, resolveHandoffToolPolicy } from "./agent/handoff";
export { Heartbeat, HeartbeatConfig, parseHeartbeatSchedule } from "./agent/heartbeat";
export type { HeartbeatAgent, HeartbeatOnError, HeartbeatOptions } from "./agent/heartbeat";
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
export { ContextAgent, createContextAgent } from "./agent/context";
export { ContextPolicy, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, promptWithHandoffInstructions } from "./agent/handoff";
export { ImageAgent, createImageAgent } from "./agent/image";
export { PromptExpanderAgent, createPromptExpanderAgent } from "./agent/prompt-expander";
export { QueryRewriterAgent, createQueryRewriterAgent } from "./agent/query-rewriter";
export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/research";
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
export { DeepResearchAgent, createDeepResearchAgent } from "./agent/research";
export { RouterAgent, createRouter, routeConditions } from "./agent/router";
export { AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, TerminalReason } from "./agent/run-outcome";
export { // Agent loop
createAgentLoop, // DevTools
enableDevTools, // MCP
@@ -959,7 +979,6 @@ export { // Agent loop
defineTool, // UI Message (AI SDK v6 parity)
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MCPClientType, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
export { AutoAgents, AutoTaskConfig, createAutoAgents } from "./auto";
export { BotRunStatus } from "./bots/protocols";
export { BaseCache, FileCache, MemoryCache, createFileCache, createMemoryCache } from "./cache";
export { CLI_SPEC_VERSION, executeCommand, parseArgs } from "./cli";
export { // Autonomy Mode
@@ -981,27 +1000,23 @@ export { // Autonomy Mode
export { // Classes
DictCondition, // Functions
evaluateCondition, // Types
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, evaluate_condition, notCondition, orConditions } from "./conditions";
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, notCondition, orConditions } from "./conditions";
export { // Enums
MemoryBackend, // Errors
ConfigValidationError, // Parse utilities
detect_url_scheme, // Presets
MEMORY_PRESETS, // Resolver functions
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, LearnBackend, LearnMode, LearnScope, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, PreCompactionMemoryFlushConfig, REFLECTION_PRESETS, RulesConfig, ToolSearchConfig, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, REFLECTION_PRESETS, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
export { createDbAdapter, db, getDefaultDbAdapter, setDefaultDbAdapter } from "./db";
export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
export { // Functions
// NB: the unaliased registerDisplayCallback name belongs to the per-type
// callback registry (callbacks module, // Types
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, async_display_callbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, errorLogs, error_logs, logError, registerDisplay, register_display_callback, syncDisplayCallbacks, sync_display_callbacks } from "./display";
export { // Types
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, errorLogs, logError, registerDisplay, syncDisplayCallbacks } from "./display";
export { // Functions
embed, // Types
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, get_dimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
export { AGENT_ERROR_KINDS, FailoverDecision, IdleTimeoutBreaker, LEGACY_ERROR_CATEGORY_MAP, LLMError, NetworkError, PraisonAIConfigError, PraisonAIError, ToolExecutionError, ValidationError, isAgentErrorKind, isErrorContext, resolveErrorCategory } from "./errors";
export type { AgentErrorKind, ErrorContextProtocol, FailoverAction, FailoverDecisionOptions, LLMErrorOptions, LegacyErrorCategory, NetworkErrorOptions, PraisonAIConfigErrorOptions, PraisonAIErrorOptions, ToolExecutionErrorOptions, ValidationErrorOptions } from "./errors";
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
export { // LLM-as-Judge
Judge, AccuracyJudge, CriteriaJudge, EvalResults, EvalSuite, Evaluator, RecipeJudge, accuracyEval, addJudge, addOptimizationRule, containsKeywordsCriterion, createDefaultEvaluator, createEvalResults, createEvaluator, getJudge, getOptimizationRule, lengthCriterion, listJudges, listOptimizationRules, noHarmfulContentCriterion, parseJudgeResponse, performanceEval, relevanceCriterion, reliabilityEval, removeJudge, removeOptimizationRule } from "./eval";
export { AgentEventBus, AgentEvents, EventEmitterPubSub, PubSub, createEventBus, createPubSub } from "./events";
@@ -1010,7 +1025,7 @@ export { // Bot types
FailoverManager, // Enums
SandboxStatus, // Gateway types
type GatewayConfig, // Other types
type ProviderStatus, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
type ProviderStatus, AutonomyLevel, RagRetrievalPolicy } from "./gateway";
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
export { // Computer Use
@@ -1022,11 +1037,7 @@ export { // Slack
createSlackBot, SlackBot, parseSlackMessage, verifySlackSignature } from "./integrations/slack";
export { BaseVectorStore, ChromaVectorStore, MemoryVectorStore, PineconeVectorStore, QdrantVectorStore, VectorQueryResult, WeaviateVectorStore, createChromaStore, createMemoryVectorStore, createPineconeStore, createQdrantStore, createWeaviateStore } from "./integrations/vector";
export { BaseVoiceProvider, ElevenLabsVoiceProvider, OpenAIVoiceProvider, createElevenLabsVoice, createOpenAIVoice } from "./integrations/voice";
export type { KnowledgeRecord } from "./knowledge";
export { GraphRAG, GraphStore, createGraphRAG } from "./knowledge/graph-rag";
export { Knowledge } from "./knowledge/knowledge";
export type { KnowledgeIndexOptions, KnowledgeSearchCallOptions, KnowledgeStoreConfig, KnowledgeStoreOptions } from "./knowledge/knowledge";
export { KnowledgeBase, KnowledgeDocument, KnowledgeSearchResult, createKnowledgeBase } from "./knowledge/rag";
export { BaseReranker, CohereReranker, CrossEncoderReranker, LLMReranker, createCohereReranker, createCrossEncoderReranker, createLLMReranker } from "./knowledge/reranker";
export { // Provider classes
OpenAIProvider, // Provider factory and utilities
@@ -1034,22 +1045,14 @@ export { // Provider classes
ProviderRegistry, // Types
type LLMProvider, AnthropicProvider, BaseProvider, GoogleProvider, ProviderMessage, ProviderToolDefinition, createProviderRegistry, getAvailableProviders, getDefaultProvider, getDefaultRegistry, hasProvider, isProviderAvailable, listProviders, parseModelString, registerBuiltinProviders, registerProvider, unregisterProvider } from "./llm/providers";
export { ADAPTERS, AISDK_PROVIDERS, COMMUNITY_PROVIDERS, PROVIDER_ALIASES } from "./llm/providers/ai-sdk/types";
export { AgentMessageEvent, CustomToolUseEvent, ManagedEvent, SessionErrorEvent, SessionIdleEvent, SessionRunningEvent, ToolConfirmationEvent, ToolUseEvent, UsageEvent, isManagedBackend } from "./managed";
export type { AgentMessageEventInit, CustomToolUseEventInit, ManagedBackendKwargs, ManagedBackendProtocol, ManagedContentBlock, ManagedEventInit, ManagedEventType, ManagedStopReason, SessionErrorEventInit, SessionIdleEventInit, ToolConfirmationEventInit, ToolUseEventInit, UsageEventInit } from "./managed";
export { MCPClient, MCPSecurity, MCPServer, MCPSessionManager, createApiKeyPolicy, createMCPClient, createMCPSecurity, createMCPServer, createMCPSession, createRateLimitPolicy, getMCPTools } from "./mcp";
export { ChromaMemory, InMemoryAdapter, MEMORY_PROVIDER_ALIASES, MemoryAdapterRegistry, addMemoryAdapter, addMemoryFactory, add_memory_adapter, add_memory_factory, createChromaMemoryAdapter, createDakeraMemoryAdapter, createMem0MemoryAdapter, createMongodbMemoryAdapter, createSqliteMemoryAdapter, getDefaultMemoryRegistry, getFirstAvailableMemoryAdapter, getMemoryAdapter, get_memory_adapter, hasMemoryAdapter, has_memory_adapter, listMemoryAdapters, list_memory_adapters, registerMemoryAdapter, registerMemoryFactory, register_memory_adapter, register_memory_factory, resetDefaultMemoryRegistry, resolveMemoryAdapterName, sanitizeChromaMetadata } from "./memory/adapters";
export type { ChromaMemoryConfig, MaybePromise, MemoryEmbedder, MemoryProtocol, MemoryRecord } from "./memory/adapters";
export { AutoMemory, AutoMemoryKnowledgeBase, AutoMemoryVectorStore, DEFAULT_POLICIES, createAutoMemory, createLLMSummarizer } from "./memory/auto-memory";
export { DocsManager, createDocsManager } from "./memory/docs-manager";
export { FileMemory, createFileMemory } from "./memory/file-memory";
export { MemoryHooks, createEncryptionHooks, createLoggingHooks, createMemoryHooks, createValidationHooks } from "./memory/hooks";
export { BaseStore, DecisionStore, FeedbackStore, ImprovementStore, InsightStore, LearnBackendNotAvailableError, LearnEntry, LearnError, LearnManager, LearnManagerMode, MongoDBLearnBackend, PatternStore, PersonaStore, RedisLearnBackend, SQLiteLearnBackend, ThreadStore, getDataDir, getLearnDir, resolveLearnConfig, toLearnEntryDict } from "./memory/learn";
export type { AsyncLearnProtocol, BaseStoreOptions, LearnEntryConvertible, LearnEntryData, LearnEntryInit, LearnEntryLike, LearnExtractor, LearnManagerConfig, LearnManagerProtocol, LearnMessage, LearnProtocol, LearnStorageBackend, LearnStore, ProcessConversationResult, ResolvedLearnConfig } from "./memory/learn";
export { Memory, createMemory } from "./memory/memory";
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
export { DEFAULT_PROFILE, HarnessProfile, listHarnessProfiles, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
export type { HarnessProfileConfig, HarnessRegistryEntry, HarnessResolverProtocol } from "./model-harness";
export { // Adapters
NoopObservabilityAdapter, // Constants
OBSERVABILITY_TOOLS, // Global adapter management
@@ -1057,6 +1060,15 @@ export { // Adapters
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
export { AgentApp, AgentAppConfig, AgentAppProtocol, AgentOS, AgentOSConfig, AgentOSProtocol, DEFAULT_AGENTOS_CONFIG, mergeConfig } from "./os";
export type { AgentAppOptions, AgentOSOptions } from "./os";
export { // P0: Call Types (new)
type MCPCall, // P0: Specialized Agent Configs (new)
type AudioConfig, // P0: Specialized Agents (new)
CodeAgent, // P1: Workflow Patterns (new)
Chunking, // P2: Context Types (new - only items not already exported)
ContextManager, // P3: Display callbacks (snake_case for Python parity)
register_display_callback, // P3: Plugin functions
get_plugin_manager, // P3: Trace & condition functions
evaluate_condition, EmbeddingAgent, If, Knowledge, MCP, OCRAgent, Parallel, RealtimeAgent, Route, Session, VideoAgent, VisionAgent, async_display_callbacks, cleanup_telemetry_resources, create_context_agent, disable_performance_mode, disable_telemetry, discover_and_load_plugins, discover_plugins, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, enable_performance_mode, enable_telemetry, ensure_plugin_dir, error_logs, get_default_plugin_dirs, get_dimensions, get_plugin_template, get_telemetry, handoff_filters, load_plugin, parse_plugin_header, parse_plugin_header_from_file, prompt_with_handoff_instructions, resolve_guardrail_policies, sync_display_callbacks, trace_context, track_workflow, when } from "./parity";
export { // Core classes
Plan, // Python parity additions
ApprovalCallback, PlanStep, PlanStorage, PlanningAgent, READ_ONLY_TOOLS, RESEARCH_TOOLS, RESTRICTED_TOOLS, TaskAgent, TodoItem, TodoList, createApprovalCallback, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
@@ -1064,51 +1076,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
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.6.155" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[crewai]"
+5 -5
View File
@@ -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():
+4 -4
View File
@@ -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__":
@@ -36,12 +36,6 @@ if __name__ == "__main__":
print(f"Custom agent max_rpm: {agent.max_rpm}")
print(f"Custom agent max_execution_time: {agent.max_execution_time}")
print(f"Custom agent max_retry_limit: {agent.max_retry_limit}")
# max_rpm auto-creates a live RateLimiter (not just a stored number)
assert agent_rate_limited.max_rpm == 10
assert agent_rate_limited._rate_limiter is not None
assert agent_rate_limited._rate_limiter.requests_per_minute == 10
print(f"Rate-limited agent limiter: {agent_rate_limited._rate_limiter}")
# Test custom agent
print("\n--- Custom Execution ---")
-43
View File
@@ -1,32 +1,5 @@
# MCP Server Examples
## Install (SHA-256)
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
```
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
```
```bash
VER=v0.6.0
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
# swap darwin-arm64 for your OS/arch
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
shasum -a 256 -c SHA256SUMS --ignore-missing
gbr-agent pair && gbr-agent run
```
Examples demonstrating the MCP Server v2 features per MCP Protocol Version 2025-11-25.
## Features Demonstrated
@@ -157,19 +130,3 @@ These examples comply with MCP Protocol Version 2025-11-25:
- `destructiveHint`: true
- `idempotentHint`: false
- `openWorldHint`: true
## Build Remote Agent (`gbr_pair.py`)
Pair a phone running [Build Remote Agent](https://grokbuildremote.com/) to a PraisonAI agent via MCP stdio `gbr-mcp` (protocol `gbr/1`). Requires `gbr-agent pair && gbr-agent run`. Phone is spectator. Never put mailbox keys in the example.
```bash
python gbr_pair.py
```
## What the phone sees
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
https://grokbuildremote.com/integrations.html
-76
View File
@@ -1,76 +0,0 @@
"""
Build Remote Agent pairing example.
Pair a phone (Build Remote Agent) to this PraisonAI session through the
free MIT gbr-agent. Protocol gbr/1. Phone is spectator + veto.
Requirements:
- gbr-agent v0.6.0+ (`gbr-agent pair && gbr-agent run`)
- GrokBuildRemote-Agents cloned; `npm install` in mcp/gbr-mcp
- pip install praisonaiagents[mcp]
Usage:
python gbr_pair.py
Never put mailbox keys in this file. Phone Settings → Bot API is the
only place the relay key is copied.
Independent product by Linespotting AB. Not affiliated with xAI or SpaceX.
https://grokbuildremote.com/
"""
from praisonaiagents import Agent, MCP
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git
## Install (SHA-256)
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
```
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
```
```bash
VER=v0.6.0
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
# swap darwin-arm64 for your OS/arch
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
shasum -a 256 -c SHA256SUMS --ignore-missing
gbr-agent pair && gbr-agent run
```
# then point args at mcp/gbr-mcp/bin/gbr-mcp.js (loopback stdio).
gbr = Agent(
instructions=(
"You can attach a phone spectator via Build Remote Agent. "
"Use GBR tools only to diagnose or attach the local Bot API "
"at 127.0.0.1:8788. Do not request mailbox keys."
),
llm="gpt-4o-mini",
tools=MCP(
"node",
args=["GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js"],
),
)
if __name__ == "__main__":
gbr.start(
"Check Build Remote Agent health on 127.0.0.1:8788 and list sessions."
)
## What the phone sees
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
https://grokbuildremote.com/integrations.html
@@ -1,110 +0,0 @@
"""
HOL Guard Tool Policy Example - PraisonAI Agents
Demonstrates using HOL Guard (an optional, external runtime security engine)
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
tool calls *before* they execute.
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
classify a shell command before execution:
hol-guard command test '<command>' --json
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
allow/benign classification. Review/block/unknown verdicts, malformed output,
timeouts, or CLI failures all short-circuit the call so the tool never runs.
pip install hol-guard # optional, external dependency
"""
import json
import shutil
import subprocess
from praisonaiagents import Agent, tool
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
COMMAND_BEARING_TOOLS = {"run_shell"}
GUARD_TIMEOUT_SECONDS = 10
@tool
def run_shell(command: str) -> str:
"""Run a shell command and return its output."""
result = subprocess.run(
command, shell=True, capture_output=True, text=True
)
return result.stdout or result.stderr
def _inspect_with_hol_guard(command: str) -> bool:
"""Return True only if HOL Guard explicitly allows the command.
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
timeout, malformed JSON, or a non-allow verdict.
"""
if not shutil.which("hol-guard"):
return False
try:
completed = subprocess.run(
["hol-guard", "command", "test", command, "--json"],
capture_output=True,
text=True,
timeout=GUARD_TIMEOUT_SECONDS,
)
except (subprocess.TimeoutExpired, OSError):
return False
if completed.returncode != 0:
return False
try:
verdict = json.loads(completed.stdout)
except (json.JSONDecodeError, ValueError):
return False
if not isinstance(verdict, dict):
return False
decision = str(verdict.get("decision", "")).lower()
return decision in ("allow", "benign")
@wrap_tool_call
def hol_guard_tool_policy(request: ToolRequest, call_next):
"""Gate command-bearing tools through HOL Guard before execution."""
if request.tool_name not in COMMAND_BEARING_TOOLS:
return call_next(request)
command = request.arguments.get("command", "")
if _inspect_with_hol_guard(command):
return call_next(request)
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
# Surface the denial in `result` too: the agent tool executor returns
# `ToolResponse.result` to the model and discards `error`, so a denial with
# only `error` set would reach the model as a null result. Keeping `error`
# populated preserves the signal for after_tool hooks and logging.
return ToolResponse(
tool_name=request.tool_name,
result=blocked_message,
error=blocked_message,
context=request.context,
)
agent = Agent(
name="GuardedShellBot",
instructions="You run shell commands only when they are safe.",
tools=[run_shell],
hooks=[hol_guard_tool_policy],
)
if __name__ == "__main__":
print("HOL Guard tool policy registered on 'run_shell'.")
print("Commands are only executed when Guard returns an explicit allow.")
print("\n✓ HOL Guard example complete")
@@ -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")
+3 -2
View File
@@ -14,7 +14,7 @@ Expected Output:
Agent responds with state persisted to Redis
"""
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents import Agent
from praisonaiagents.db import db
print("=== Redis State Store (Agent-First) ===")
@@ -28,7 +28,8 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
memory=my_db,
session_id="redis-state-example"
)
# Chat - state is automatically managed via Redis
@@ -1,111 +0,0 @@
"""
Human Review Between Sequential AgentTeam Tasks
================================================
Demonstrates a human-in-the-loop review gate between sequential tasks using the
existing ``on_task_complete`` hook — no new API required.
IMPORTANT — two different ``on_task_complete`` hooks exist, with DIFFERENT
signatures. Do not confuse them:
AgentTeam(hooks=MultiAgentHooksConfig(on_task_complete=fn))
-> fn(task, task_output) # team-level; gets the Task and its output
Task(on_task_complete=fn)
-> fn(task_output) # 1 param: just the TaskOutput
-> fn(task_output, metadata) # 2+ params: TaskOutput + metadata dict
# NOTE: NOT (task, task_output)
This example uses the TEAM-level hook so we get ``(task, task_output)`` and can
filter which tasks require review by ``task.name``.
Approve / reject / edit:
- approve -> return, workflow continues
- reject -> raise; see "stopping the workflow" note below
- edit -> mutate ``task_output.raw``; downstream tasks that read this task via
``context=[...]`` pick up the edited text
Stopping the workflow on reject
-------------------------------
The TEAM-level hook logs and swallows exceptions, so a bare ``raise`` inside it
does NOT stop the sequential run today. To hard-stop on reject, use the PER-TASK
callback path with the 1-argument signature and ``fail_on_callback_error=True``
(see ``review_output`` / ``t1_strict`` at the bottom of this file).
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.config.feature_configs import MultiAgentHooksConfig
# Only these task names trigger a human review gate.
REVIEW_TASKS = {"research"}
# --- Team-level review gate: signature is (task, task_output) ---------------
def review_gate(task, task_output):
if task.name not in REVIEW_TASKS:
return
print(f"\n--- Review: {task.name} ---\n{task_output.raw}\n")
choice = input("Approve (y), reject (n), edit (e)? ").strip().lower()
if choice == "n":
# NOTE: team-level hook swallows this; see per-task strict path below
# for a version that actually halts the workflow.
raise RuntimeError(f"Human rejected task '{task.name}'")
if choice == "e":
# task.result is the same object as task_output, so mutating .raw here
# propagates to downstream tasks that use context=[this_task].
task_output.raw = input("Edited output: ").strip()
researcher = Agent(name="Researcher", role="Researcher", goal="Research", backstory="Expert")
writer = Agent(name="Writer", role="Writer", goal="Write", backstory="Writer")
t1 = Task(
name="research",
description="List 5 facts about {{topic}}", # {{...}} is the substitution syntax
expected_output="5 bullets",
agent=researcher,
)
t2 = Task(
name="summary",
description="Write 3-sentence summary",
expected_output="3 sentences",
agent=writer,
context=[t1], # reads the (possibly edited) output of t1
)
team = AgentTeam(
agents=[researcher, writer],
tasks=[t1, t2],
process="sequential",
variables={"topic": "REST APIs"}, # substituted into {{topic}} in task descriptions
hooks=MultiAgentHooksConfig(on_task_complete=review_gate),
)
# --- Alternative: per-task strict gate that HALTS on reject -----------------
# Per-task Task.on_task_complete uses the 1-argument signature (task_output).
# With fail_on_callback_error=True a raised exception re-raises and stops the run.
def review_output(task_output):
print(f"\n--- Review output ---\n{task_output.raw}\n")
if input("Approve? (y/n) ").strip().lower() == "n":
raise RuntimeError("Human rejected output")
t1_strict = Task(
name="research",
description="List 5 facts about {{topic}}",
expected_output="5 bullets",
agent=researcher,
variables={"topic": "REST APIs"},
on_task_complete=review_output, # signature: (task_output)
fail_on_callback_error=True, # required so reject halts the workflow
)
if __name__ == "__main__":
# NOTE: template values come from AgentTeam(variables=...), NOT start(inputs=...).
# start() forwards **kwargs but does not consume an ``inputs`` argument.
team.start()
@@ -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 -11
View File
@@ -1,15 +1,5 @@
import threading
import time
from praisonaiagents import Agent
if __name__ == "__main__":
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
server = threading.Thread(
target=lambda: agent.launch(port=8080, protocol="mcp", path="/mcp"),
daemon=True,
)
server.start()
time.sleep(2)
print("MCP server started on http://127.0.0.1:8080/mcp")
agent.launch(port=8080, protocol="mcp", path="/mcp")
@@ -23,7 +23,6 @@ from praisonaiagents.hooks.verification import (
VerificationResult,
BaseVerificationHook,
CommandVerificationHook,
FileCheckHook,
)
@@ -101,19 +100,7 @@ def main():
print(f" Name: {cmd_hook.name}")
print(f" Command: {cmd_hook.command}")
print(f" Timeout: {cmd_hook.timeout_seconds}s")
# 6. FileCheckHook: declarative, no-shell completion gate
print("\n6. FileCheckHook (declarative completion gate):")
file_hook = FileCheckHook(
name="changelog", path="CHANGELOG.md", non_empty=True
)
file_result = file_hook.run()
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
print(
" Gate wiring: when verification_hooks are configured, the autonomous "
"loop cannot finalise with a success outcome while a blocking hook fails."
)
print("\n" + "=" * 60)
print("✓ Verification hooks example completed successfully!")
print("=" * 60)
@@ -86,7 +86,7 @@ def method_1_simple_loop():
agents=[loop_agent],
tasks=[loop_task],
process="workflow",
execution="balanced" # max_iter=10 preset to prevent infinite loops
max_iter=10 # Maximum iterations to prevent infinite loops
)
print(f"🚀 Starting loop processing of {csv_file}")
@@ -150,7 +150,7 @@ def main():
agent = Agent(
name="AI Research Assistant",
instructions="You are an AI research assistant. Use your knowledge base to answer questions about artificial intelligence topics.",
knowledge={"sources": sample_documents, "rerank": True},
knowledge={**rerank_config, "sources": sample_documents},
llm="gpt-4o-mini"
)
@@ -64,7 +64,7 @@ def main():
agents=[url_agent],
tasks=[url_task],
process="workflow",
execution="balanced" # max_iter=10 preset; adjust for more URLs
max_iter=10 # Adjust based on how many URLs you have
)
print(f"🚀 Processing URLs from {csv_file}")
+1 -2
View File
@@ -7,8 +7,7 @@ multiple agents and managing real-time communication.
from praisonaiagents import Agent, GatewayConfig, SessionConfig
# Configure the gateway using the typed SDK GatewayConfig.
# In gateway.yaml, the optional ``gateway:`` block is validated at load time (#3050).
# Configure the gateway
gateway_config = GatewayConfig(
host="127.0.0.1",
port=8765,
@@ -141,7 +141,7 @@ class DynamicAgentSystem:
agents=agents,
tasks=tasks,
process=process,
output="verbose" if inputs['depth'] == 'detailed' else "silent"
verbose=inputs['depth'] == 'detailed'
)
result = praison_agents.start()
+2 -2
View File
@@ -7,12 +7,12 @@ allowed_dirs = [
"/path/to/other/allowed/dir"
]
# Pass the executable as the first token and the allowed directories as args
# Use a single string command with allowed directories
filesystem_agent = Agent(
instructions="""You are a helpful assistant that can interact with the filesystem.
Use the available tools when relevant to manage files and directories.""",
llm="gpt-4o-mini",
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
tools=MCP("npx -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
)
filesystem_agent.start("List files in the allowed directories")
@@ -67,7 +67,6 @@ agents_system = AgentTeam(
print("Starting graph memory demonstration...")
result = agents_system.start()
result_text = str(result) if result is not None else ""
print(f"\nGraph Memory Result: {result_text[:200]}...")
print(f"\nGraph Memory Result: {result[:200]}...")
print("\n✅ Graph memory integration complete!")
print("Agent built knowledge graph and performed relationship-aware queries.")
@@ -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)
@@ -66,7 +66,7 @@ research_task1 = Task(
agents_session1 = AgentTeam(
agents=[research_agent],
tasks=[research_task1],
output="verbose"
session=session1, output="verbose"
)
# Execute first session
@@ -105,7 +105,7 @@ analysis_task = Task(
agents_session2 = AgentTeam(
agents=[analysis_agent],
tasks=[analysis_task],
output="verbose"
session=session2, output="verbose"
)
print("Starting analysis session with previous context...")
@@ -155,7 +155,7 @@ synthesis_task = Task(
agents_recovery = AgentTeam(
agents=[synthesis_agent],
tasks=[synthesis_task],
output="verbose"
session=recovery_session, output="verbose"
)
print("Starting recovery session with full context from previous sessions...")
@@ -205,7 +205,7 @@ review_task = Task(
agents_reviewer = AgentTeam(
agents=[review_agent],
tasks=[review_task],
output="verbose"
session=reviewer_session, output="verbose"
)
print("Starting peer review session by different user...")
@@ -77,35 +77,13 @@ class TrafilaturaTools:
except ValueError:
# Not an IP address, continue with domain validation
pass
# Block common internal domains
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
return False
# Block metadata service endpoints
if hostname in ['169.254.169.254', 'metadata.google.internal']:
return False
# Resolve the hostname and reject if any address is internal. The
# checks above only inspect IP *literals*, so a plain domain (or an
# encoded form like a decimal IP or "<internal-ip>.nip.io") still
# passes even though it resolves to an internal address. Resolving
# here closes that SSRF bypass, mirroring the resolve-and-check
# approach already used in praisonaiagents/tools/file_tools.py.
import socket
default_port = 443 if parsed.scheme == 'https' else 80
try:
addrinfos = socket.getaddrinfo(hostname, parsed.port or default_port)
except socket.gaierror:
# Hostname does not resolve; treat it as unsafe.
return False
for addrinfo in addrinfos:
resolved = ipaddress.ip_address(addrinfo[4][0])
if (resolved.is_private or resolved.is_reserved
or resolved.is_loopback or resolved.is_link_local):
return False
return True
return hostname not in ['169.254.169.254', 'metadata.google.internal']
except Exception:
return False
@@ -279,17 +257,7 @@ class TrafilaturaTools:
"newspaper": None,
"spider": None
}
# Validate the URL once before any fetcher runs so the newspaper and
# requests paths below are protected against SSRF, just like the
# trafilatura path (which validates inside extract_content).
if not self._validate_url(url):
error = {"error": f"Invalid or unsafe URL: {url}"}
comparison["trafilatura"] = error
comparison["newspaper"] = error
comparison["spider"] = error
return comparison
# Get Trafilatura extraction
try:
comparison["trafilatura"] = self.extract_content(url)
+1 -1
View File
@@ -15,4 +15,4 @@ if completed.status == "completed":
# List all videos
videos = agent.list()
# Models: openai/sora-2, gemini/veo-3.1-generate-preview, azure/sora-2
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
+1 -1
View File
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
+1 -1
View File
@@ -13,7 +13,7 @@ Usage:
python mcp_http_server.py
# Or use CLI:
praisonai mcp serve --transport http-stream --port 8080
praisonai serve mcp --transport http --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
+3 -3
View File
@@ -14,9 +14,9 @@ datasets:
# A small verified subset for a cheap smoke run. Verify against the registry
# with `harbor datasets list` before editing.
task_names:
- terminal-bench/build-pmars
- terminal-bench/bn-fit-modify
- terminal-bench/break-filter-js-from-html
- hello-world
- fix-permissions
- csv-to-parquet
agents:
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
+14 -29
View File
@@ -13,14 +13,15 @@ Usage:
-n 4
Architecture:
Harbor Container → `praisonai code -p --output json "TASK" --dangerously-skip-approval`
Harbor Container → `praisonai code "TASK" --dangerously-skip-approval` (headless)
Notes:
- `--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.
- `praisonai code` normally exits 0 and Harbor grades by task verification,
so a benchmark miss does not fail the run — but the real exit status is
propagated so genuine install/auth/startup failures still surface.
- The base `praisonai` package is sufficient; heavy `code` extras are not
required (ACP tools degrade gracefully).
@@ -28,7 +29,6 @@ Dependencies:
pip install harbor praisonai praisonaiagents
"""
import json
import shlex
try:
@@ -81,19 +81,16 @@ class PraisonAICodeAgent(BaseInstalledAgent):
model = self.model_name or "openai/gpt-4o-mini"
command = (
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
"chmod 600 \"$log_file\"; "
f"praisonai code -p --output json {shlex.quote(instruction)} "
f"praisonai code {shlex.quote(instruction)} "
f"--dangerously-skip-approval "
f"--model {shlex.quote(model)} "
"> \"$log_file\" 2>&1; "
"> /tmp/praisonai_code.log 2>&1; "
# Capture the real exit status so install/auth/startup failures still
# surface instead of being silently masked (Harbor otherwise records a
# completed attempt for a crashed agent).
"status=$?; "
# Echo the envelope so Harbor captures it for post-run accounting.
"cat \"$log_file\"; "
"rm -f \"$log_file\"; "
# Tee the log into a stable path so populate_context_post_run can read it.
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
# `praisonai code` normally exits 0 (Harbor grades by task
# verification), so a genuine benchmark miss won't fail here — but a
# nonzero status means the assistant itself failed to run.
@@ -103,35 +100,23 @@ class PraisonAICodeAgent(BaseInstalledAgent):
# `--ae` / job YAML env vars arrive via BaseAgent.extra_env and are wired
# into the container exec context by Harbor Trial, so no per-exec `env=`
# is needed here.
result = await self.exec_as_agent(
await self.exec_as_agent(
environment,
command=command,
)
self._last_stdout = getattr(result, "stdout", "") or ""
def populate_context_post_run(self, context: AgentContext) -> None:
"""Parse the headless JSON envelope into Harbor's accounting fields."""
envelope = None
for line in reversed(getattr(self, "_last_stdout", "").splitlines()):
try:
candidate = json.loads(line)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(candidate, dict) and "status" in candidate:
envelope = candidate
break
"""`praisonai code` exposes no machine-readable metrics headlessly.
usage = envelope.get("usage", {}) if envelope else {}
context.n_input_tokens = usage.get("in")
context.n_output_tokens = usage.get("out")
context.cost_usd = usage.get("cost")
Record identifying metadata; token/cost accounting is not available from
the CLI's mixed stdout, so we leave those fields unset rather than guess.
"""
context.metadata = {
"framework": "praisonai",
"agent_type": "code-cli",
"agent_name": self.name(),
"model": self.model_name,
"session_id": envelope.get("session_id") if envelope else None,
"status": envelope.get("status") if envelope else "unknown",
"log_path": "/tmp/praisonai_code.log",
}
@@ -34,7 +34,6 @@ except ImportError as e:
try:
from praisonaiagents import Agent
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
from praisonaiagents.config.feature_configs import ExecutionConfig
except ImportError as e:
raise ImportError(
f"PraisonAI not installed: {e}\n"
@@ -42,13 +41,6 @@ except ImportError as e:
) from e
TB_EXECUTION = ExecutionConfig(
max_tool_calls_per_turn=80,
max_iter=40,
max_steps=80,
)
class PraisonAIExternalAgent(BaseAgent):
"""External PraisonAI agent that drives a Harbor container environment."""
@@ -150,14 +142,26 @@ class PraisonAIExternalAgent(BaseAgent):
),
tools=[bash_tool],
llm=self.model_name or "openai/gpt-4o-mini",
execution=TB_EXECUTION,
)
# Execute one deliberately long coding turn. Harbor's hidden verifier
# is the only completion authority; model or pytest text is not.
# Execute the agent with outer loop to handle premature stopping
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
result = await agent.achat(instruction)
print("✅ PraisonAI Agent completed task")
for _iter in range(19):
result_str = str(result)
# Stop if test passed
if any(sig in result_str.lower() for sig in [
" passed", "passed ", "test passed", "all tests pass", "1 passed"
]):
break
result = await agent.achat(
"The task is NOT complete yet — keep working. Run bash_tool commands now. "
"If you haven't already: (1) read all files in /app/, "
"(2) run the test to see the exact failure, "
"(3) implement a fix, (4) run the test again. "
"Repeat until the test passes. What is your next bash_tool command?"
)
print(f"✅ PraisonAI Agent completed task")
# Populate Harbor context with metadata
self._populate_context(agent, context, result)
@@ -199,10 +203,6 @@ class PraisonAIExternalAgent(BaseAgent):
"tools_used": ["bash_tool"],
"framework": "praisonai",
"version": self.version(),
"stop_reason": getattr(agent, "last_stop_reason", None),
"max_tool_calls_per_turn": TB_EXECUTION.max_tool_calls_per_turn,
"max_iter": TB_EXECUTION.max_iter,
"max_steps": TB_EXECUTION.max_steps,
}
except Exception as e:
@@ -218,4 +218,4 @@ if __name__ == "__main__":
print(" --model openai/gpt-4o-mini")
print()
print("Dependencies:")
print(" pip install harbor praisonaiagents")
print(" pip install harbor praisonaiagents")
@@ -1,39 +0,0 @@
"""Static regression checks for the in-tree Harbor adapter configuration."""
from pathlib import Path
import yaml
EXAMPLES = Path(__file__).parent
def test_external_adapter_sets_coding_execution_budget():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert "ExecutionConfig" in source
assert "max_tool_calls_per_turn=80" in source
assert "max_iter=40" in source
assert "max_steps=80" in source
def test_external_adapter_does_not_guess_completion_from_pytest_text():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert '"1 passed"' not in source
assert '"test passed"' not in source
def test_smoke_yaml_uses_terminal_bench_21_registry_ids():
job = yaml.safe_load(
(EXAMPLES / "job_code_smoke.yaml").read_text(encoding="utf-8")
)
names = job["datasets"][0]["task_names"]
assert names
assert all(name.startswith("terminal-bench/") for name in names)
assert names == [
"terminal-bench/build-pmars",
"terminal-bench/bn-fit-modify",
"terminal-bench/break-filter-js-from-html",
]
+18 -18
View File
@@ -14,18 +14,18 @@ def test_imports():
"""Test that all required modules can be imported."""
try:
from praisonaiagents import Agent
print("[OK] Agent imported successfully")
print("✅ Agent imported successfully")
from praisonaiagents.tools import execute_command
print("[OK] execute_command tool imported successfully")
print("✅ execute_command tool imported successfully")
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
print("[OK] Approval system imported successfully")
print("✅ Approval system imported successfully")
return True
except ImportError as e:
print(f"[FAIL] Import failed: {ascii(e)}")
print(f"❌ Import failed: {e}")
return False
def test_agent_creation():
@@ -40,11 +40,11 @@ def test_agent_creation():
)
assert agent.name == 'test-agent'
print("[OK] Agent creation successful")
print("✅ Agent creation successful")
return True
except Exception as e:
print(f"[FAIL] Agent creation failed: {ascii(e)}")
print(f"❌ Agent creation failed: {e}")
return False
def test_approval_system():
@@ -55,11 +55,11 @@ def test_approval_system():
registry = get_approval_registry()
registry.set_backend(AutoApproveBackend())
print("[OK] Approval system configuration successful")
print("✅ Approval system configuration successful")
return True
except Exception as e:
print(f"[FAIL] Approval system test failed: {ascii(e)}")
print(f"❌ Approval system test failed: {e}")
return False
def test_external_agent_import():
@@ -80,11 +80,11 @@ def test_external_agent_import():
# Now try to import our agent
import praisonai_external_agent
print("[OK] External agent import successful")
print("✅ External agent import successful")
return True
except Exception as e:
print(f"[FAIL] External agent import failed: {ascii(e)}")
print(f"❌ External agent import failed: {e}")
return False
def test_multi_agent_import():
@@ -104,11 +104,11 @@ def test_multi_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import multi_agent_example
print("[OK] Multi-agent example import successful")
print("✅ Multi-agent example import successful")
return True
except Exception as e:
print(f"[FAIL] Multi-agent example import failed: {ascii(e)}")
print(f"❌ Multi-agent example import failed: {e}")
return False
def test_wrapper_agent_import():
@@ -128,11 +128,11 @@ def test_wrapper_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import praisonai_wrapper_agent
print("[OK] Wrapper agent import successful (CLI-based approach)")
print("✅ Wrapper agent import successful (CLI-based approach)")
return True
except Exception as e:
print(f"[FAIL] Wrapper agent import failed: {ascii(e)}")
print(f"❌ Wrapper agent import failed: {e}")
return False
if __name__ == "__main__":
@@ -152,19 +152,19 @@ if __name__ == "__main__":
total = len(tests)
for test_name, test_func in tests:
print(f"\n[TEST] {test_name}...")
print(f"\n🧪 Testing {test_name}...")
try:
if test_func():
passed += 1
except Exception as e:
print(f"[FAIL] Test {test_name} crashed: {ascii(e)}")
print(f"❌ Test {test_name} crashed: {e}")
print("\n" + "=" * 55)
print(f"Results: {passed}/{total} tests passed")
if passed == total:
print("[OK] ALL BASIC TESTS PASSED!")
print("🎉 ALL BASIC TESTS PASSED!")
print("The Terminal-Bench integration components are properly structured.")
else:
print("[FAIL] Some tests failed - check output above")
print("❌ Some tests failed - check output above")
sys.exit(1)
+2 -4
View File
@@ -30,11 +30,9 @@ def main():
else:
print(f"Found {len(stories)} top stories:")
for story in stories:
title = story.get("title") or "N/A"
url = story.get("url") or "N/A"
print(f" - {title[:50]}...")
print(f" - {story.get('title', 'N/A')[:50]}...")
print(f" Score: {story.get('score', 'N/A')} | Comments: {story.get('descendants', 'N/A')}")
print(f" URL: {url[:60]}...")
print(f" URL: {story.get('url', 'N/A')[:60]}...")
print()
print("✅ Hacker News tool working correctly!")
+10 -14
View File
@@ -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)
@@ -1,64 +0,0 @@
# Simple Sequential Team (YAML)
A minimal 2-agent sequential team: a **researcher** gathers facts, then a
**writer** summarizes them. This is the copy-paste starting point for the most
common multi-agent pattern (2–5 agents, run in order).
```
research_task -> summary_task
(researcher) (writer)
```
## Files
| File | Purpose |
|------|---------|
| `agents.yaml` | Roles (who) + their tasks (what); tasks run in declaration order |
> **Which loader applies?** This example uses the canonical single-file format
> consumed by `AgentsGenerator.generate_crew_and_kickoff()`
> (`src/praisonai/praisonai/agents_generator.py`). Agents live under `roles:`,
> each with nested `tasks:`. **Tasks run in the order they are declared** — the
> researcher's task first, then the writer's. (An optional top-level
> `dependencies:` block is included to document intent and mirror the canonical
> fixture, but the roles-file loader sequences by declaration order, so keep
> tasks in the order you want them to run.) No new loader module is required —
> this is the existing YAML path.
## Run it
```bash
export OPENAI_API_KEY=sk-...
praisonai examples/yaml/teams/research-writer/agents.yaml
```
This positional-file form is the canonical roles-file entry point. From Python
the equivalent is `praisonai.run("agents.yaml")` (see
`src/praisonai/praisonai/_entrypoint.py`), which dispatches to the same
`AgentsGenerator`.
Change the subject by editing the `topic:` line in `agents.yaml` (interpolated
into every `{topic}` placeholder).
## When to use this vs a workflow YAML
| You need… | Use | Example |
|-----------|-----|---------|
| 2–5 agents, sequential/hierarchical | **This** (`roles:` + `tasks:`) | `agents.yaml` here |
| Routing by classifier output | workflow YAML | `../../workflows/routing_workflow.yaml` |
| Parallel branches | workflow YAML | `../../workflows/parallel_workflow.yaml` |
| Loops / planning / memory | workflow YAML | `../../workflows/complete_workflow.yaml` |
## Python equivalent
```python
from praisonaiagents import Agent, Task, PraisonAIAgents
researcher = Agent(role="Senior Researcher", goal="Find accurate information")
writer = Agent(role="Report Writer", goal="Turn research into summaries")
t1 = Task(description="Research renewable energy and list 5 key facts", agent=researcher)
t2 = Task(description="Write a 3-sentence summary from the research", agent=writer, context=[t1])
PraisonAIAgents(agents=[researcher, writer], tasks=[t1, t2]).start()
```
@@ -1,34 +0,0 @@
# yaml-language-server: $schema=https://docs.praison.ai/schemas/agents.schema.json
# Minimal sequential team: researcher -> writer
# Run: praisonai examples/yaml/teams/research-writer/agents.yaml
framework: "praisonai"
topic: "renewable energy"
roles:
researcher:
role: "Senior Researcher"
goal: "Find accurate information about {topic}"
backstory: "Expert at concise, factual research."
tasks:
research_task:
description: "Research {topic} and list 5 key facts."
expected_output: "A bullet list of 5 facts."
writer:
role: "Report Writer"
goal: "Turn research into readable summaries about {topic}"
backstory: "Clear technical writer."
tasks:
summary_task:
description: "Write a 3-sentence executive summary from the research."
expected_output: "3 sentences, plain text."
# Ordering is by task declaration order above (researcher's task runs first,
# then the writer's), which is how the roles-file loader sequences tasks.
# The optional `dependencies:` block below mirrors the canonical fixture
# (src/praisonai/tests/agents.yaml) and documents intent; the roles-file
# loader does not consume it, so keep tasks in the order you want them run.
dependencies:
- task: summary_task
depends_on: research_task
@@ -1,45 +0,0 @@
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `tools_run_on:` is the only line that differs from a normal local workflow.
# Every step shares the same sandbox and working directory, so the file the
# writer creates is there for the reader. Thinking and orchestration still run
# on your machine, and the sandbox is torn down when the run finishes.
#
# Places: docker | e2b | modal | daytona | flyio | tenki | local
# subprocess | sandlock | ssh | novita
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
# - subprocess is process separation only, NOT a security boundary
#
# `run_on:` is still accepted here as the old spelling for this key.
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
tools_run_on: docker
agents:
writer:
role: File Writer
goal: Write files into the shared workspace
instructions: >
You have write_file and execute_command running on a remote Linux
sandbox. Use them; never say you cannot run commands.
reader:
role: File Reader
goal: Read files back out of the shared workspace
instructions: >
You have read_file and execute_command running on the same remote
sandbox the writer used. Use them.
steps:
- agent: writer
action: "Use write_file to put exactly 'hello from step 1' in /workspace/note.txt"
- agent: reader
action: "Use read_file on /workspace/note.txt and report its exact contents"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# C14 import-direction gate for praisonai-deploy.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODE_ROOT="${C14_CODE_ROOT:-src/praisonai-code/praisonai_code}"
DEPLOY_ROOT="${C14_DEPLOY_ROOT:-src/praisonai-deploy/praisonai_deploy}"
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
ANY_CODE_RE='(^from praisonai_code([[:space:]]|\.)|^import praisonai_code($|\.))'
ANY_DEPLOY_RE='(^[[:space:]]*from praisonai_deploy([[:space:]]|\.)|^[[:space:]]*import praisonai_deploy($|\.))'
echo "== C14 praisonai_deploy wrapper import gate (must be zero outside bridges) =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: praisonai_deploy imports the praisonai wrapper"
exit 1
fi
echo "deploy wrapper import gate ok"
echo "== C14 praisonai_deploy module-level praisonai_code import gate =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_CODE_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_CODE_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: module-level praisonai_code import in praisonai_deploy"
exit 1
fi
echo "deploy code import gate ok"
echo "== C14 praisonai-code hot-path gate (no praisonai_deploy at module level) =="
for f in \
"$CODE_ROOT/cli/main.py" \
"$CODE_ROOT/cli/app.py" \
"$CODE_ROOT/cli/commands/run.py" \
"$CODE_ROOT/cli/commands/chat.py" \
"$CODE_ROOT/cli/commands/code.py"
do
if [ -f "$f" ] && head -n 80 "$f" | grep -E "$ANY_DEPLOY_RE" 2>/dev/null; then
echo "FAIL: module-level praisonai_deploy import in code hot path $f"
exit 1
fi
done
echo "code hot-path gate ok"
echo "C14 import gates passed"
+1 -3
View File
@@ -18,9 +18,7 @@ for f in \
"$ROOT/cli/app.py" \
"$ROOT/cli/commands/run.py" \
"$ROOT/cli/commands/chat.py" \
"$ROOT/cli/commands/code.py" \
"$ROOT/cli/interactive/async_tui.py" \
"$ROOT/cli/interactive/core.py"
"$ROOT/cli/commands/code.py"
do
if grep -E "$HOT_PATH_RE" "$f" 2>/dev/null; then
echo "FAIL: module-level wrapper import in $f"
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Lint and template-render all Helm charts under package infra/ trees.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v helm >/dev/null 2>&1; then
echo "FAIL: helm CLI not found (install via https://helm.sh/docs/intro/install/)"
exit 1
fi
HELM_ROOTS=(
"$ROOT/src/praisonai-bot/infra/helm"
"$ROOT/src/praisonai-deploy/infra/helm"
)
charts=()
for helm_root in "${HELM_ROOTS[@]}"; do
if [ ! -d "$helm_root" ]; then
continue
fi
shopt -s nullglob
for chart in "$helm_root"/*/; do
if [ -f "${chart}Chart.yaml" ]; then
charts+=("$chart")
fi
done
shopt -u nullglob
done
if [ ${#charts[@]} -eq 0 ]; then
echo "No charts found under package infra/helm trees"
exit 1
fi
for chart in "${charts[@]}"; do
name="$(basename "$chart")"
echo "== helm lint: $name =="
helm lint "$chart"
echo "== helm template: $name =="
case "$name" in
praisonai-gateway)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
>/dev/null
;;
praisonai-agents-api)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
--set postgres.auth.existingSecret=ci-smoke-postgres \
>/dev/null
;;
*)
helm template "ci-smoke-$name" "$chart" >/dev/null
;;
esac
done
echo "Helm chart gates passed (${#charts[@]} chart(s))"
@@ -99,12 +99,13 @@ pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
```
13. **praisonai-platform** advisories are handled in a different repository:
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
It is not part of this monorepo and is not published from here. Triage, patch,
release and advisory publication all happen in that repo, on its own semver line.
Nothing in this repository imports it, so a platform advisory never requires an
SDK release.
13. Publish **praisonai-platform** (only when the advisory targets platform code):
```bash
cd /Users/praison/praisonai-package/src/praisonai-platform
praisonai publish pypi
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
```
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
```bash
@@ -112,8 +113,9 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
npm version patch && npm publish
```
15. Verify npm when applicable:
15. Verify platform / npm when applicable:
```bash
pip index versions praisonai-platform | head -1
npm view praisonai version
```
@@ -137,7 +139,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
}
EOF
```
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
- `<LAST_VULNERABLE_VERSION>`: version before the fix
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
- Always include credits for the reporter
@@ -188,8 +190,8 @@ After validating each advisory in source code, assign **one** label:
| Package | Version line | Notes |
|---|---|---|
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
@@ -215,11 +217,9 @@ Close (or publish advisory with no code change only if already mitigated) when:
|---|---|---|---|
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
`praisonai-platform` is **not audited from this repository** — it lives in
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
## RESOURCES & DOCUMENTATION
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
- ALWAYS set patched_versions before publishing advisory
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
- Platform-only: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
- ALWAYS request CVE AFTER publishing advisory
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
+2 -23
View File
@@ -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?
@@ -147,7 +140,7 @@ The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends,
**Monorepo bootstrap:** `praisonai._bootstrap.ensure_praisonai_code()` adds sibling `src/praisonai-code` to `sys.path` so `PYTHONPATH=src/praisonai-agents:src/praisonai` works without an explicit code entry.
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai-bot` → `praisonai-train` → `praisonai-browser` → `praisonai-mcp` → `praisonai-sandbox` → `praisonai-deploy` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Invocation paths that must remain valid:**
@@ -214,7 +207,6 @@ and features (capabilities, bots, framework adapters) — use
| 2b | `praisonai-bot` | Bots, gateway, channel CLI, OS daemon |
| 2c | `praisonai-train` | LLM fine-tuning (Unsloth), agent training, `train` CLI |
| 3 | `praisonai` | `framework_adapters/`, capabilities, serve orchestration, dashboard |
| 1-TS | `praisonai-ts` | TypeScript/JavaScript SDK (`npm: praisonai`); canonical path `src/praisonai-ts/` |
**Cross-tier rule:** `praisonai-code` must not declare a PyPI dependency on `praisonai`. Use
[`praisonai_code._wrapper_bridge`](../praisonai-code/praisonai_code/_wrapper_bridge.py) for lazy wrapper access.
@@ -230,17 +222,6 @@ from `praisonai_train.cli.commands.*` (`train`, C10); `_WRAPPER_RESIDENT_COMMAND
Full boundary doc: [`src/praisonai/tests/C7.1_BOUNDARIES.md`](../praisonai/tests/C7.1_BOUNDARIES.md).
### 2.5 TypeScript SDK (`praisonai-ts`)
Python guidelines in this file do **not** replace the TypeScript SDK guide. For any issue, PR, or review touching `src/praisonai-ts/`:
1. Read [`src/praisonai-ts/AGENTS.md`](../praisonai-ts/AGENTS.md) — architecture, parity, triage, and PR review rules.
2. Implement fixes in **`src/praisonai-ts/`** in this monorepo (not in `praisonaiagents/`).
3. Verify with `cd src/praisonai-ts && npm install && npm run build && npm test`.
4. After merge, maintainers sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`.
[MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) is the public npm checkout; direction is **monorepo → praisonai-js** only. Claude merge gate and `@claude` review TS changes under `src/praisonai-ts/` using the TS AGENTS.md criteria (lightweight, type-safe, Python parity where applicable).
---
## 3. Core SDK Architecture (praisonaiagents)
@@ -895,8 +876,6 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
| CLI backends | `praisonai_code/cli_backends/` |
| Back-compat shims | `praisonai/cli/_shim.py`, `praisonai/cli/main.py` |
| C5 regression tests | `praisonai/tests/unit/test_c5_backward_compat.py` |
| TypeScript SDK | `src/praisonai-ts/` — see `src/praisonai-ts/AGENTS.md` |
| npm mirror (sync only) | [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) |
---
@@ -1095,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
+54 -54
View File
@@ -17,78 +17,78 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 57 | 18177 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
| ✅ Approval | 1 | 7 | 3616 |
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 46 | 14657 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2312 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3879 |
| ✅ Autonomy | 3 | 7 | 2794 |
| ✅ Bots | 7 | 33 | 13928 |
| ✅ Auto Generation | 5 | 10 | 2880 |
| ✅ Autonomy | 3 | 7 | 2707 |
| ✅ Bots | 7 | 29 | 11795 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 123 | 38201 |
| ✅ Chunking | 2 | 2 | 424 |
| ✅ CLI | 5 | 119 | 34087 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1386 |
| ✅ Configuration | 3 | 8 | 3323 |
| ✅ Context Management | 16 | 36 | 14104 |
| ✅ Code Execution | 2 | 13 | 4031 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 7 | 2772 |
| ✅ Context Management | 16 | 34 | 13204 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 844 |
| ✅ Embeddings | 6 | 23 | 2051 |
| ✅ Evaluation | 1 | 8 | 3220 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1631 |
| ✅ Failover | 2 | 1 | 480 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 86 | 32228 |
| ✅ Guardrails | 4 | 4 | 2388 |
| ✅ Handoffs | 11 | 6 | 2718 |
| ✅ Hooks | 2 | 9 | 4690 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1528 |
| ✅ Failover | 2 | 1 | 400 |
| ✅ Files | 2 | 6 | 1964 |
| ✅ Flow | 1 | 3 | 777 |
| ✅ Gateway | 7 | 56 | 20195 |
| ✅ Guardrails | 4 | 4 | 1673 |
| ✅ Handoffs | 11 | 6 | 2454 |
| ✅ Hooks | 2 | 9 | 4128 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 18 | 5562 |
| ✅ LLM | 3 | 15 | 5917 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 62 | 14489 |
| ✅ Memory | 6 | 19 | 7588 |
| ✅ Knowledge | 4 | 13 | 4160 |
| ✅ LLM | 3 | 14 | 4864 |
| ✅ Loops | 4 | 4 | 1202 |
| ✅ MCP | 1 | 58 | 13322 |
| ✅ Memory | 6 | 17 | 6635 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2815 |
| ✅ Observability | 2 | 23 | 2779 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1273 |
| ✅ Output | 3 | 5 | 1158 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3124 |
| ✅ Prompts | 2 | 9 | 2018 |
| ✅ Providers | 1 | 57 | 8636 |
| ✅ Planning | 6 | 6 | 1545 |
| ✅ Plugins | 8 | 3 | 1900 |
| ✅ Prompts | 2 | 9 | 1913 |
| ✅ Providers | 1 | 55 | 7502 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3122 |
| ✅ RAG | 5 | 15 | 2936 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1042 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1044 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3670 |
| ✅ Security | 1 | 3 | 3558 |
| ✅ Sessions | 4 | 20 | 7898 |
| ✅ Skills | 6 | 16 | 5984 |
| ✅ Tasks | 2 | 8 | 3231 |
| ✅ Telemetry | 1 | 2 | 660 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44258 |
| ✅ Sandbox | 5 | 6 | 2700 |
| ✅ Security | 1 | 3 | 2970 |
| ✅ Sessions | 4 | 13 | 5286 |
| ✅ Skills | 6 | 13 | 4946 |
| ✅ Tasks | 2 | 6 | 2743 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Templates | 1 | 8 | 1657 |
| ✅ Tools | 12 | 133 | 37934 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 6 | 728 |
| ✅ Vector Store | 1 | 12 | 1193 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 10 | 2791 |
| ✅ Workflows | 5 | 18 | 7446 |
| ✅ Web | 3 | 8 | 2055 |
| ✅ Workflows | 5 | 16 | 6546 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (43 docs, 7573 lines)
- ℹ️ Documents (1 docs, 810 lines)
- ℹ️ Database (40 docs, 6184 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -1,331 +0,0 @@
# Ground truth
Every claim here was measured, not estimated. Each carries **how** it was obtained so it can
be re-checked cheaply. Measured 2026-09-03 against `origin/main` at `2591aa405`, with a live
Ollama 0.33.2 (`b79067b0`) on `127.0.0.1:11434`.
If a work order contradicts something here, re-measure before acting. Numbers drift.
> **Capabilities are per-model, not per-server.** An early draft of this file attributed the
> 9B model's capability list to `qwen3:0.6b`. Re-verified 2026-09-03: `qwen3:0.6b` is
> `["completion","tools","thinking"]` — **no vision**. Always name the model when quoting a
> capability set.
---
## 1. Runtime facts, verified live
| Fact | Value | How to re-verify |
|---|---|---|
| Ollama liveness probe | `GET /` returns `200` with body `Ollama is running` | `curl -sI 127.0.0.1:11434/` |
| Ollama version probe | `{"version":"0.33.2"}` | `curl -s 127.0.0.1:11434/api/version` |
| Capability probe works | `qwen3:0.6b` → `["completion","tools","thinking"]` (the 9B tamil model, separately, → `[...,"vision"]`) | `curl -s :11434/api/show -d '{"model":"qwen3:0.6b"}' \| jq .capabilities` |
| Unknown `options` keys are silently dropped | `{"totally_bogus_key":123}` → HTTP 200, no warning | send it to `/api/chat`; response is a normal completion |
| Tool-param schema keywords are stripped | `minLength`, `format`, `default`, `additionalProperties` all absent from the rendered prompt | `/api/chat` with `_debug_render_only: true` and those keys set |
| `parameters.type` must be a bare string | `"type": ["object","null"]` → **HTTP 400** `cannot unmarshal array into Go struct field ToolFunctionParameters...type of type string` | send that tool definition to `/api/chat` |
| **`format` + `tools` are mutually destructive** | Tool call silently suppressed; model fabricates. Same request without `format` calls the tool correctly. | see §2 below |
### Installed on this machine
`ollama` (0.33.2), `llama-server` (b7620, Homebrew), `mlx_lm.server`, `hf` CLI.
**Not** installed: `lms` (LM Studio), `vllm`.
Ports open at time of measurement: `11434` only.
---
## 2. The reproduction that matters most
`format` (JSON schema) plus `tools`, sent to Ollama, produces a **confident fabrication** with
an HTTP 200 and no warning. The JSON grammar makes the tool-call tag unemittable, so the
model cannot call the tool and answers from nothing instead.
```
POST /api/chat model=qwen3:0.6b think=false
prompt: "What is the weather in Paris? Use the tool."
tools: [get_weather(city: string)]
WITH format + tools -> HTTP 200
message.tool_calls : null
message.content : {"answer": "The weather in Paris is sunny with a temperature of 15C."}
TOOLS ONLY (format removed) -> HTTP 200
message.tool_calls : [{"function": {"name": "get_weather",
"arguments": {"city": "Paris"}}}]
message.content : ""
```
Consequence for design: this combination must **raise**, not be silently repaired. A
fabricated answer that looks correct is worse than an error.
---
## 3. Codebase measurements
All obtained by script over `praisonaiagents/` at `2591aa405`.
### Scale
| Metric | Value |
|---|---|
| Python files / lines | 595 / 238,936 |
| Files over 2,000 lines | 13 |
| `agent/agent.py` | 7,701 |
| `llm/llm.py` | 7,160 |
| `agent/chat_mixin.py` | 6,151 |
| `llm/openai_client.py` | 2,691 |
| `llm/adapters/__init__.py` | 340 |
### `LLM` class cohesion
95 methods, 6,779 lines. Three largest methods total 3,087 lines — 43% of the file.
| Lines | Location | Method | Branch/loop/try nodes |
|---|---|---|---|
| 1,675 | `llm.py:2557` | `get_response` | **194** |
| 969 | `llm.py:4706` | `get_response_async` | 115 |
| 443 | `llm.py:4233` | `get_response_stream` | 56 |
| 249 | `llm.py:5966` | `_build_completion_params` | 52 |
| 199 | `llm.py:413` | `__init__` | 10 |
### Adapter call sites — the A1 finding
`DefaultAdapter` declares **17 methods** (an earlier draft said 18; the 18th item,
`add_provider_adapter`, is a module-level function). Eleven methods have **zero** call sites
anywhere, and with the three module functions that is 12 dead items. Note also that
`LLMProviderAdapterProtocol` declares only **16** — it omits `get_streaming_adapter`, so that
method is not part of the protocol it claims to implement.
```
supports_streaming 2
format_tool_result_message 1 (llm.py:1712)
get_default_settings 1 (llm.py:690)
should_summarize_tools 1
supports_prompt_caching 1
supports_streaming_with_tools 1
--- zero call sites ---
recover_tool_calls_from_text 0 (inline copy at llm.py:3468-3500)
parse_tool_calls 0
handle_empty_response_with_tools 0
post_tool_iteration 0
format_tools 0
get_streaming_adapter 0
get_max_iteration_threshold 0
should_skip_streaming_with_tools 0
supports_structured_output 0
inject_cache_control 0
extract_reasoning_tokens 0
add_provider_adapter 0 (public registration hook)
```
Ollama references (case-insensitive): `llm/llm.py` **145**, `llm/adapters/__init__.py` 19,
`llm/streaming_protocol.py` 11, `tools/call_executor.py` 6.
### Compensation coverage across the three response paths — the A2 finding
```
get_response _async _stream
1675 L 969 L 443 L
tool_result_mapping (value fix-up) 8 0 0
max_tool_repairs (repair loop) 2 0 0
force_tool_usage (prompt inject) 1 0 0
_validate_and_filter_ollama_arguments 1 1 0
_generate_ollama_tool_summary 1 1 0
_handle_ollama_sequential_logic 1 1 0
_format_ollama_tool_result_message 1 1 1* (indirect)
```
`get_response_stream` has none of the seven — except `_format_ollama_tool_result_message`,
which it reaches indirectly via `_create_tool_message` (`llm.py:6791`), so that row overstated
the gap. A fuller re-measurement found **16** divergent compensations, not 7; three of them run
*against* the sync path. The full matrix is in `06-adapter-revival.md` §2.6. `difflib` similarity of `get_response` against
`get_response_async` is **22.3%**, while the file's other four sync/async pairs are 76–91% —
so those two have diverged, not merely duplicated.
### Import graph — the I6 constraint
65 subpackages, 212 directed edges, **26 mutually-importing pairs**. Heaviest:
```
agent <-> tools 53 + 1 llm/ imports: tools 10, streaming 7,
agent <-> approval 28 + 1 agent 4, telemetry 2,
agent <-> config 25 + 1 auth 2, compaction 2,
agent <-> llm 19 + 4 context 1, thinking 1
llm <-> tools 10 + 1
context <-> llm 2 + 1 15 subpackages import llm/
```
### Reachability — the A3 finding
21 modules call a provider SDK directly, bypassing both clients: 23 `litellm.completion/acompletion`,
7 `litellm.embedding/aembedding`, 16 `chat.completions.create`, 3 bare `OpenAI()`.
These nine contain **zero** occurrences of `api_base` or `base_url`, so there is no parameter
to set — "run everything locally" is currently unachievable, not merely unconfigured:
```
memory/memory.py workflows/workflows.py eval/judge.py
eval/grader.py eval/accuracy.py context/compressor.py
context/optimizer.py lite/__init__.py telemetry/performance_monitor.py
```
Worst single case, `memory/memory.py:2186-2192`:
```python
from openai import OpenAI
client = OpenAI() # no base_url, no api_key
response = client.chat.completions.create(
model=llm or "gpt-4o-mini", ...
```
Hardcoded defaults in code (excluding docstrings): **105** `"gpt-4o-mini"`, **17**
`"text-embedding-3-small"`. Sixteen of the 105 are the identical line
`self.llm if hasattr(self,'llm') else "gpt-4o-mini"` in `agent/context_agent.py`.
### Stringly-typed provider identity — the A6 finding
**77 occurrences of 23 distinct string predicates** across `src/`. The same provider is
tested inconsistently, which is precisely how D3 and D4 arose:
```
9 startswith("anthropic/") gemini tested 3 ways: gpt tested 4 ways:
8 startswith("ollama/") startswith("gemini") startswith('gpt-')
7 "ollama" in startswith("gemini/") startswith("gpt-")
6 startswith("gemini") startswith('gemini-') startswith("gpt")
5 startswith("openai/") "gpt" in
5 startswith("claude")
3 ":11434" in
```
### Accretion history — why boundaries matter here
| File | First | Now | Commits | Ever shrank |
|---|---|---|---|---|
| `llm/llm.py` | 1,212 (Jan 2025) | 7,160 | 251 | never, in 18 sampled months |
| `agent/agent.py` | 1,251 (Jan 2025) | 7,701 | 472 | once — the split below |
| `agent/chat_mixin.py` | created Apr 2026 | 6,151 | 150 | — |
On **2026-04-01** `agent.py` was split 8,915 → 5,030 by extracting `chat_mixin.py`. Five
months later the two halves total **13,852 lines — 55% above the pre-split file**, growing
~33 lines/day. Moving code did not create a boundary. This is the argument for I6 being a
*test* rather than a convention.
---
## 4. Dependency facts
| Package | Version present | Status |
|---|---|---|
| `httpx` | 0.28.1 | **hard transitive dep** of required `openai>=2.0.0` (`httpx<1,>=0.23.0`) |
| `aiohttp` | 3.14.1 | direct required core dep |
| `openai` | 2.44.0 | direct required core dep |
| `litellm` | 1.90.2 | optional (`llm`, `memory` extras) |
`pyproject.toml` declares **17 optional extras** — `a2ui, api, all, auth, autonomy, crawl,
dakera, graph, knowledge, llm, mcp, memory, mongodb, os, sandbox, search, telemetry` — and
**none of them is local**. `import praisonaiagents` measures 0.04 s, so imports are lazy and
cheap; discovery latency is the only startup cost to manage.
**Conclusion (superseded).** This section originally concluded `local/` should use `httpx`.
That was reversed by the normative decision in `07-local-package-spec.md` §3: `httpx` is present
only as an **undeclared transitive** edge (`openai -> httpx`), so building the dependency sink on
it means a future `openai` release dropping `httpx` would silently break `local/`. The client is
therefore stdlib `urllib.request`, and the boundary test forbids `httpx`/`requests`/`aiohttp`.
The measurement above stands; only the recommendation drawn from it was wrong.
---
## 5. litellm prefix resolution vs ours
litellm 1.90.2 resolves all of these; PraisonAI's `_detect_provider` recognises only `ollama/`:
```
ollama/llama3.2 -> ollama OllamaAdapter repairs=2
ollama_chat/llama3.2 -> openai DefaultAdapter repairs=0 <- litellm's RECOMMENDED prefix
lm_studio/qwen -> openai DefaultAdapter repairs=0
vllm/x -> openai DefaultAdapter repairs=0
hosted_vllm/x -> openai DefaultAdapter repairs=0
huggingface/x -> openai DefaultAdapter repairs=0
```
---
## 6. Known-bad environment note
`mervinpraison/praisonai-qwen3.5-9b-tamil-en2ta:latest` does **not load** on Ollama 0.33.2:
`Failed to load CLIP model from ...sha256-11540ddc21b3...`, HTTP 500. The vision projector
blob is the failing piece. Use `qwen3:0.6b` for local verification instead. Unrelated to this
work, but it will waste an agent's time if not known.
---
## 7. The three response paths disagree — measured, not inferred
Live Ollama, `temperature=0`, identical prompt and tool, two trials, reproducible
(script in `06-adapter-revival.md` §2.7):
```
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
async calls=1 'Paris: 21C sunny'
stream calls=10 ''
```
> **Corrected 2026-09-03.** An earlier draft recorded the sync path returning
> `'{\n\n\n}'` with one tool call. That did not reproduce; the figures above did,
> across two trials. The finding is unchanged — the paths disagree and the stream
> path is broken — but the `'{\n\n\n}'` sample should not be cited.
Three facts follow, and they reframe the work:
1. **The best-compensated path produces the worst answer.** `get_response` carries 16 of the 18
compensations and calls the tool twice, returning a doubled answer;
`get_response_async` carries 8 and returns the correct answer once. More compensation is
not the goal — *the same* compensation is.
2. **The stream path is not under-compensated, it is broken.** It burns all 10 iterations of
`max_iter`, invokes the tool **ten times** for a one-tool question, and yields nothing. That
is a cost and side-effect amplifier, not a formatting defect.
3. Leading hypothesis for the sync/async split: `OLLAMA_FINAL_ANSWER_PROMPT` (`llm.py:5302`,
async only). **Still a hypothesis, not a measurement** — it was not settled during order 06,
because the corrected baseline changed what needs explaining.
## 8. Two test-infrastructure traps
- **`src/praisonai-agents/tests/` is almost entirely outside CI.** The only tests any workflow
runs from it are `tests/smoke/test_subscription_auth.py` and
`tests/unit/auth/test_agent_auth_wiring.py`. `tests/unit/test_adapter_registry.py` (38 tests)
and all 13 files in `tests/unit/llm/` run **nowhere**. Put new tests in
`src/praisonai/tests/unit/llm/`, which is run.
- **`test_ollama_tool_reliability.py` tests a copy, not the product.** Its `MockLLM`
re-implements `_is_ollama_provider`, `_apply_ollama_defaults`, `_validate_tool_call`,
`_should_force_tool_usage` and both prompt templates. It cannot fail in response to any change
in `llm.py`. Do not treat it as a safety net.
## 9. Test-gating facts (order 04)
- **`not network`, not `provider_ollama`, is what deselects the mocked base_url suite.** The gating
plugin implies `network` from *any* provider marker (`test_gating.py:235-238`) and every CI
expression contains `not network`. Editing the workflows' `-m` strings accomplishes nothing.
- `tests/unit/**` is **exempt** from provider auto-detection (`test_gating.py:215`,
`test_type != 'unit'`). That is why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI
while the integration file does not — and why live tests placed under `tests/unit/` need an
**explicit** marker.
- `-m provider_ollama` currently selects **32 tests across 5 files**; only 7 are Ollama tests.
- **4 of the 7 tests in `test_base_url_api_base_fix.py` fail when actually executed** —
`LLMResponseError: 'str' object has no attribute 'choices'`. The mock returns a dict; the
tool-calling loop needs an object with `.choices`.
- **`tests/test_double_api_fix.py` makes a real network call when forced to run.** `llm.py` routes
`gpt-4o-mini` through `litellm.responses` (`llm.py:6434`), not `litellm.completion`, so its patch
no longer intercepts (verified: 401 from `api.openai.com/v1/responses`). It is currently masked
by an unconditional auto-skip — **anyone who "fixes" the skip without fixing the mock gets live
billed calls.**
- **`network_guard.py` disables itself** for anything under `/integration/`, `/e2e/`, `/live/`
(lines 103-105) and for any `provider_*`-marked test (96-100). The suite's network blocker does
not cover the tests most likely to make network calls.
- **A live Ollama suite already exists** and has never run:
`tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on
`PRAISONAI_TEST_OLLAMA`. It is broken against `main` — all 7 pass `verbose=True` to `Agent()`,
and two pass `force_tool_usage=` / `max_tool_repairs=`; all three now raise `TypeError`.
- **`qwen3:0.6b` is 522 MB** (not ~400 MB) and reports `completion, tools, thinking` — **no
vision**. Reliability through `Agent`: default 3/4; `force_tool_usage="always"` + `temperature=0`
**6/6**; multi-step arithmetic 0/2 (one hung past 90 s).
@@ -1,372 +0,0 @@
# 04 — D7: provider markers are assigned per file, so mocked tests are gated as if live
**Branch:** `fix/gating-per-test-provider-markers`
**Measured:** clean worktree at `origin/main` (`2591aa405`), working tree clean.
**Owns:** `src/praisonai/tests/_pytest_plugins/test_gating.py`, both `pytest.ini` files,
`src/praisonai/tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`,
`tests/unit/cli/test_test_command.py`.
**Must not edit:** any `praisonaiagents/` source; `.github/workflows/**` (order 05).
> **The audit's stated root cause was wrong, and the correction changes the fix.**
> `provider_ollama` is a red herring. **`not network` deselects all 7 tests on its own**, because
> the plugin implies `network` from *any* provider marker and the file also matches
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
> The fix must be marker-side, not workflow-side. This is why order 04 touches no YAML.
---
## 1. Corrections to the audit
| # | Claim | Verdict |
|---|---|---|
| 1 | 4 CI marker expressions carry `not provider_ollama` | **True but incomplete** — there is a 5th, non-CI location: `src/praisonai-code/praisonai_code/cli/commands/test.py:67` (`praisonai test --tier main`). |
| 2 | 7 tests, all deselected | **Exactly true** (`collected 7 items / 7 deselected / 0 selected`). **Stated cause is wrong** — see the callout above. |
| 3 | The file never inspects `litellm.completion` kwargs | **True** (only `mock_completion.assert_called()`). **And worse: 4 of the 7 tests fail outright when executed.** They are not merely toothless, they are broken. |
| 4 | `test_double_api_fix.py`: 0 asserts, 34 prints | **True. And worse:** all 3 tests are auto-`skip`ped in every configuration, and when forced to run its `patch('litellm.completion')` no longer intercepts — `llm.py` routes `gpt-4o-mini` through `litellm.responses`, so it makes a **real network call** (verified: 401 from `api.openai.com/v1/responses`). |
| 5 | `test_ollama_tool_reliability.py` asserts against MockLLM | **True.** 541 lines, MockLLM at 23–197, 28 tests, all pass. **Quantified:** 2 of the 8 re-implemented methods (`_apply_ollama_defaults`, `_try_parse_tool_call_json`) **do not exist on the real `LLM` at all.** |
| 6 | agents-side ollama tests gated only by `OLLAMA_HOST` | **True**, and `test_ollama_fix_openai` is gated only by `OPENAI_API_KEY` — a live **billed** call on a plain `pytest`. Mitigating: no CI workflow runs these two files. |
| 7 | No ollama live job | **True for CI**, but the audit **missed that the suite already exists**: `tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on `PRAISONAI_TEST_OLLAMA=1`. It is **broken against current `main`**. See order 05. |
---
## 2. Reproduce before the fix
```bash
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
CI_M="not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
```
**R1 — 100% deselected:**
```bash
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q --disable-warnings
# collected 7 items / 7 deselected / 0 selected
```
**R2 — `provider_ollama` is a red herring.** Rerun R1 with `and not provider_ollama` **removed**:
still `7 deselected / 0 selected`. `not network` does it alone.
**R3 — the whole file is tagged.** With a marker-dump plugin, all seven items carry the identical
set `['integration', 'network', 'provider_ollama', 'provider_openai', 'skip']` — including the six
that never mention Ollama.
**R4 — 4 of 7 fail when actually executed:**
```bash
PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all OPENAI_API_KEY=sk-fake-offline \
python -m pytest tests/integration/test_base_url_api_base_fix.py -o addopts="" -q --tb=no -rA
# 4 failed, 3 passed
```
All four fail with `LLMResponseError: ... 'str' object has no attribute 'choices'`. The three that
pass are the three that never call `get_response()`.
**R5 — `-m provider_ollama` selects 4× more than it should:**
```
7 tests/integration/test_base_url_api_base_fix.py
7 tests/integration/test_ollama_tool_calling_live.py
11 tests/integration/test_rag_integration.py
4 tests/integration/test_setup_integration.py
3 tests/test_double_api_fix.py
```
**32 selected; only 7 are Ollama tests.** This is why order 04 is a hard prerequisite for order 05 —
`-m provider_ollama` is currently not a usable selector.
---
## 3. Root cause
`test_gating.py:153-166` scans the **entire file**:
```python
153 def _detect_providers_in_file(filepath: Path) -> Set[str]:
154 """Detect which providers are referenced in a test file."""
...
161 content = _get_file_content(filepath)
162 detected = set()
163 for marker, pattern in PROVIDER_PATTERNS.items():
164 if pattern.search(content):
165 detected.add(marker)
166 return detected
```
Line 28 is `'provider_ollama': re.compile(r'\b(ollama)\b', re.IGNORECASE)`. One occurrence inside
`test_ollama_environment_variable_compatibility` tags all seven items (applied at lines 213–230).
Note line 215: `if item.fspath and test_type != 'unit'` — **`tests/unit/**` is exempt**, which is
why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI while the integration file does not.
**The second mechanism, which the audit missed**, is lines 235–238:
```python
235 # 3. Add network marker if any provider marker is present
236 provider_markers = {m for m in existing_markers if m.startswith('provider_')}
237 if provider_markers and 'network' not in existing_markers:
238 item.add_marker(pytest.mark.network)
```
A mocked test that merely *mentions* `openai` gets `network`, and every CI expression contains
`not network`. Lines 262–267 then convert it into a runtime skip.
**Sub-defect 3.** Lines 59, 145, 177, 234 are all bare `mock_completion.assert_called()`. Nothing
reads `call_args`. Measured: `LLM(model='openai/mistral', base_url='http://localhost:4000',
api_key='sk-test').get_response("test", stream=False)` calls `litellm.completion` with
`base_url='http://localhost:4000'` and `api_base=None`. **So the file's title is stale** — the real
invariant is *`base_url` survives into the call*, not "maps to `api_base`".
---
## 4. The change
**Design decision: AST per-test marking, not file splitting.**
- *Split the file* — cost cheap, **benefit zero** (measured, R2): the six non-Ollama tests still
match `provider_openai`, still get `network`, still get deselected. It also leaves `-m
provider_ollama` selecting 32 tests and fixes nothing for the other 14 mixed-provider files.
**Rejected.**
- *AST per-test marking* — one change in one plugin, fixes the class of problem, and is the
prerequisite for order 05's selector to mean anything. **Recommended.**
### Sub-change 1 — per-test provider detection via AST
Keep `_detect_providers_in_file` (it now serves only the `network` marker) and add:
```python
def _detect_providers_in_text(text: str) -> Set[str]:
"""Return every provider marker whose pattern appears in ``text``."""
detected = set()
for marker, pattern in PROVIDER_PATTERNS.items():
if pattern.search(text):
detected.add(marker)
return detected
def _build_per_test_provider_map(filepath: Path) -> Optional[Dict[tuple, Set[str]]]:
"""Map ``(class_name, func_name)`` -> provider markers for one test file.
The text considered for a test is that test's own source segment plus its
decorators -- not the whole file. Returns ``None`` if the file cannot be
parsed, so callers fall back to whole-file rather than under-marking.
"""
key = str(filepath)
if key in _file_provider_cache:
return _file_provider_cache[key]
result: Optional[Dict[tuple, Set[str]]] = None
try:
source = _get_file_content(filepath)
tree = ast.parse(source)
result = {}
def _visit(node, class_name=None):
for child in getattr(node, 'body', ()):
if isinstance(child, ast.ClassDef):
_visit(child, child.name)
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
segment = ast.get_source_segment(source, child) or ""
for decorator in child.decorator_list:
segment += "\n" + (ast.get_source_segment(source, decorator) or "")
result[(class_name, child.name)] = _detect_providers_in_text(segment)
_visit(tree)
except (SyntaxError, ValueError, RecursionError):
result = None
_file_provider_cache[key] = result
return result
def _detect_providers_for_item(item) -> Set[str]:
"""Provider markers for a single collected item (per test, not per file)."""
filepath = Path(item.fspath)
if _is_excluded_path(str(filepath)):
return set()
per_test = _build_per_test_provider_map(filepath)
if per_test is None:
return _detect_providers_in_file(filepath)
parts = item.nodeid.split("::")[1:]
if not parts:
return _detect_providers_in_file(filepath)
func = parts[-1].split("[")[0]
cls = parts[-2] if len(parts) >= 2 else None
if (cls, func) in per_test:
return set(per_test[(cls, func)])
if (None, func) in per_test:
return set(per_test[(None, func)])
# Dynamically generated item we cannot locate in the AST: stay conservative.
return _detect_providers_in_file(filepath)
```
Add `import ast`, a `_file_provider_cache: Dict[str, Optional[Dict[tuple, Set[str]]]] = {}` next to
`_file_content_cache` (line 46), and clear it in `pytest_configure` (186) and
`pytest_sessionfinish` (294).
### Sub-change 2 — `offline` opt-out, and pin `network` to the file
Replace lines 213–230 with a version that (a) honours an explicit `offline` marker, (b) calls
`_detect_providers_for_item`, and (c) **keeps `network` whole-file derived**:
```python
offline = 'offline' in existing_markers
if not offline:
detected_providers = _detect_providers_for_item(item)
for marker, pattern in PROVIDER_PATTERNS.items():
if pattern.search(item.nodeid):
detected_providers.add(marker)
for provider in detected_providers:
if provider not in existing_markers:
item.add_marker(getattr(pytest.mark, provider))
# The `network` marker stays WHOLE-FILE derived. Narrowing
# the provider markers must not, by itself, un-deselect a
# test that is really live -- only `offline` does that.
if _detect_providers_in_file(filepath) and 'network' not in existing_markers:
item.add_marker(pytest.mark.network)
```
**Why `network` stays file-derived — this is the whole backward-compatibility story and is not
optional.** Measured: if per-test narrowing were also allowed to narrow `network`, **48 currently-
deselected integration tests would newly select, 25 of them with no `skipif` — they would actually
execute**, across nine files including `test_rag_live.py` and `test_model_routing_real.py`, in a
suite where the network guard is disabled for everything under `/integration/`
(`network_guard.py:103-105`). Pinning `network` to the file makes sub-change 1 provably
**selection-neutral** under every existing `-m` expression, while still making `-m provider_ollama`
mean what it says.
Register the marker in both `pytest.ini` files and `tests/conftest.py`:
```
offline: Fully mocked - opt out of provider/network auto-marking
```
### Sub-change 3 — opt the file in, and make it guard its bug
Add `pytestmark = pytest.mark.offline` plus a response factory:
```python
def make_completion_response(text="Test response"):
"""A litellm.completion stand-in the LLM tool-calling loop can consume.
A bare dict is NOT sufficient: the loop reads ``.choices[0].message`` and a
dict raises ``'str' object has no attribute 'choices'``.
"""
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = text
resp.choices[0].message.tool_calls = None
resp.choices[0].finish_reason = "stop"
return resp
```
For each of the four broken tests: replace the dict `return_value` with
`make_completion_response(...)`, pass `stream=False` to `get_response`, and replace
`mock_completion.assert_called()` with a real kwarg assertion:
```python
kwargs = mock_completion.call_args.kwargs
assert kwargs['base_url'] == 'http://localhost:4000'
assert kwargs['api_key'] == 'sk-test'
assert kwargs['model'] == 'openai/mistral'
```
- `test_koboldcpp_specific_scenario` → `kwargs['base_url'] == "http://127.0.0.1:5001/v1"`
- `test_litellm_documentation_example_compatibility` → `kwargs['base_url'] == "http://0.0.0.0:4000"`
- `test_ollama_environment_variable_compatibility` → `kwargs['model'] == 'ollama/llama2'` **and**
`'base_url' not in kwargs` (none was configured; the env path must not inject one)
All four were run standalone against `main` and pass. Leave the three currently-passing tests alone.
Update the stale module docstring ("maps base_url to api_base" → "base_url must reach litellm") but
**do not rename the test functions** — the nodeids are referenced in issue discussion, and renaming
would change what the nodeid scan at plugin lines 224–226 sees.
---
## 5. Backward-compatibility contract
Baseline, measured (`tests/integration/`, CI ignores applied): **290 collected, 187 selected,
103 deselected** under the CI marker expression.
> Caveat for the executing agent: a cold first invocation on a fresh interpreter has been observed
> to report `157/260`, apparently an optional-dependency import warming effect. **Run it twice**;
> `187/290/103` is stable from the second run on. Take your own baseline before touching anything.
**Tests whose selection status changes: exactly 7**, all in `test_base_url_api_base_fix.py`, all
deselected → selected. Post-fix expectation: **194/290 (96 deselected)**. Nothing else moves in
either direction — that is what the file-derived `network` guarantees.
Intended change under `-m provider_ollama` (32 → 9):
| file | before | after |
|---|---|---|
| `test_ollama_tool_calling_live.py` | 7 | 7 |
| `test_base_url_api_base_fix.py` | 7 | 0 (`offline`) |
| `test_rag_integration.py` | 11 | 1 |
| `test_setup_integration.py` | 4 | 0 |
| `test_double_api_fix.py` | 3 | 1 |
**The risk, stated plainly.** Un-deselecting tests turns CI red if any fail. **They do: 4 of the 7
fail today** (R4). This is not hypothetical.
**Mitigation — mandatory, not optional: land the gating fix and the four repairs in the same PR.**
Do not merge sub-changes 1+2 with the `pytestmark` but without the repairs. **Quarantining with
`xfail` is explicitly rejected** — the repairs are ~10 lines each, verified working, and an `xfail`
here would recreate the exact defect being fixed (a test that names a bug and does not guard it).
**Escape hatch.** If the 7 prove flaky in CI for a reason not reproducible locally, revert
**sub-change 3 only**. Selection returns to 187/290/103 and the plugin improvements stay.
---
## 6. Tests to add
In `tests/unit/cli/test_test_command.py` (the existing home of gating-plugin tests, and already in
`EXCLUDED_PATHS`, so these cannot be auto-marked by the thing they test), add
`class TestProviderMarkerGranularity` with:
- `test_ollama_does_not_leak_to_sibling_tests` — **THE meta-test.** If this fails, D7 has regressed.
- `test_openai_does_not_leak_to_sibling_tests`
- `test_plain_test_gets_no_provider_markers`
- `test_decorators_are_scanned` — a `@pytest.mark.parametrize("m", ["ollama/llama2"])` must mark it
- `test_unparseable_file_falls_back_to_whole_file` — conservative over-marking on a parse failure
- `test_network_marker_remains_whole_file_derived` — pins the decision that keeps this change
selection-neutral for the 96 still-deselected tests
- `test_offline_marker_is_registered` — both `pytest.ini` files, or every use warns
No new dependency: `ast`, `tmp_path` and `configparser` are stdlib/pytest builtins.
---
## 7. Verification and acceptance
```bash
# 1. The 7 are now selected
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q
# expect: 7 selected, 0 deselected
# 2. They pass offline, with no keys and no services
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u OLLAMA_HOST \
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" -o addopts="" -q
# expect: 7 passed
# 3. Selection neutral everywhere else
python -m pytest tests/integration/ $IG -o addopts="" -q --collect-only -m "$CI_M" | tail -1
# expect: 194/290 tests collected (96 deselected)
# 4. `-m provider_ollama` now means Ollama -> 7 + 1 + 1, not 32
# 5. Meta-tests pass
# 6. The three CI shards reproduced verbatim do not regress
# 7. The two pytest.ini marker tables stay in sync
```
**Acceptance gate:** `tests/integration/` collects **194/290 (96 deselected)** — exactly seven more
than baseline, all seven from `test_base_url_api_base_fix.py` — **and passes with
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `OLLAMA_HOST` all unset and no Ollama server running.**
**PR title:** `fix(tests): mark provider tests per test function, not per file`
---
## 8. Filed for separate PRs
- **`tests/test_double_api_fix.py`'s mock no longer intercepts.** `llm.py` routes `gpt-4o-mini`
through `litellm.responses` (`llm.py:6434`), not `litellm.completion`. Forced to run, it makes a
**real OpenAI request** (verified: 401 for `https://api.openai.com/v1/responses`). Today it is
masked by an unconditional auto-`skip` — **anyone who "fixes" the skip without fixing the mock
gets live billed calls.**
- **`network_guard.py` disables itself for `/integration/`, `/e2e/`, `/live/`** (lines 103–105) and
for any `provider_*`-marked test (96–100). The suite's network blocker does not cover the tests
most likely to make network calls.
@@ -1,298 +0,0 @@
# 05 — Live local-model CI job
**Branch:** `feat/ci-ollama-live-job`
**Depends on:** order `04` must merge first. Before it, `-m provider_ollama` selects 32 tests
across 5 files (25 of which are not Ollama tests) and this job is unbuildable.
**Owns:** `.github/workflows/test-optimized.yml`,
`src/praisonai/tests/integration/test_ollama_tool_calling_live.py`.
**Must not edit:** `test_gating.py` (order 04); the `smoke` / `main` / `openai-live` jobs; anything
in `test-core.yml`; `src/praisonai-agents/tests/test_ollama_{fix,async_fix}.py`.
---
## 1. The suite already exists — and is broken
The audit concluded "no ollama live job, and no live suite". The first half is right; **the second
is wrong**. `src/praisonai/tests/integration/test_ollama_tool_calling_live.py` is 230 lines,
7 tests, gated at lines 19–22 on `PRAISONAI_TEST_OLLAMA`. It has never run anywhere.
Measured against `main`, **it cannot run** — three `Agent()` kwargs are now rejected:
```
$ python -c "from praisonaiagents import Agent; Agent(name='C', llm='ollama/qwen3:0.6b', verbose=True)"
TypeError: Agent.__init__() got unexpected keyword argument(s): verbose
verbose: verbosity moved into output=; use output='verbose' (or output=OutputConfig(verbose=True)).
$ ... Agent(..., force_tool_usage="always") -> TypeError: unexpected keyword argument(s): force_tool_usage
$ ... Agent(..., max_tool_repairs=3) -> TypeError: unexpected keyword argument(s): max_tool_repairs
```
All 7 tests use `verbose=True`; two also use the other two. **Every test errors on construction.**
The modern form, verified working:
```python
Agent(name="C",
llm={"model": "ollama/qwen3:0.6b", "force_tool_usage": "always", "max_tool_repairs": 3},
tools=[calculator])
# -> llm_instance.force_tool_usage == 'always', llm_instance.max_tool_repairs == 3
```
So this order is **"fix a dead test file and wire it up"**, not "write a live suite".
---
## 2. Model selection
Measured on a real Ollama 0.33.2 host:
```
$ ollama list
qwen3:0.6b 7df6b6e09427 522 MB
$ ollama show qwen3:0.6b
parameters 751.63M quantization Q4_K_M context length 40960
Capabilities: completion, tools, thinking
```
**Two corrections:** the blob is **522 MB**, not ~400 MB; and it is **not vision capable** —
`completion, tools, thinking` only. Tools + thinking is all this job needs.
**Reliability, measured end-to-end through `praisonaiagents.Agent`:**
| configuration | trials | tool invoked + correct answer | latency |
|---|---|---|---|
| default | 4 | **3/4** — one run returned `{}` with no tool call | 0.5–5.4 s |
| `force_tool_usage="always"`, `temperature=0` | 6 | **6/6** | 0.4–4.0 s |
| indirect prompt ("I know it's easy, but use the tool") | 1 | 0/1 — emitted `{"type": "calculator", ...}`, wrong key | 0.6 s |
| multi-step `(17+25)+(8+9)` | 2 | 1 hung past 90 s; 1 answered 42 instead of 59 | — |
**Conclusion: `qwen3:0.6b` is the right model, but only the forced, zero-temperature, single-call
prompt is CI-deterministic.**
Rejected alternatives: `qwen2.5:0.5b` (~400 MB, older tool calling, unmeasured — no reason to
prefer it over a verified model); `llama3.2:1b` (~1.3 GB, 2.5× the cache for no measured gain);
`smollm2:135m` (**no `tools` capability — disqualifying**); `olmo-3` (the file's current default,
~7B, far too large per run). Keep `olmo-3` as the *local* default so developer workflow is
unchanged; let CI override via env var.
**Cache:** `~/.ollama/models`, keyed `${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}`.
**No `restore-keys`** — a partial restore of a *different* model's blobs is worse than a clean
pull, and a stale key would silently test the wrong model.
**Install:** pinned release tarball, not `curl https://ollama.com/install.sh | sh`. The install
script tracks latest, so the job would silently change what it tests, and it pulls GPU runtimes a
CPU-only runner never uses. Expect ~20–60 s tarball + ~30–90 s cold model pull, near-zero on a
cache hit — **the executing agent must confirm real timings on the first green run and write them
into a YAML comment.**
**Gating:** `if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' ||
github.event.inputs.tier == 'nightly'` — byte-identical to `extended-anthropic` (line 467),
`extended-google` (525), `extended-groq` (585), `extended-xai` (644), `extended-cohere` (703).
**It never runs on a pull request.**
---
## 3. One deliberate departure from the sibling jobs
Every existing live job ends `|| echo "... tests completed"`, so **it can never fail**. That is
defensible for key-dependent jobs — a rotated secret should not page anyone — but it is exactly
the "test that cannot fail" pattern this whole effort exists to remove, and **Ollama needs no
secret**: availability is fully under the job's control.
So the job has **two run steps**:
1. a **required** minimum-contract step with **no `|| echo`**;
2. an **informational** step for the measured-flaky remainder, keeping the sibling style.
**Do not merge them.**
---
## 4. Test-file changes
1. After `pytestmark`, add the model indirection:
```python
# CI overrides this with a small, fast model; local runs keep olmo-3.
OLLAMA_MODEL = "ollama/" + os.getenv("PRAISONAI_OLLAMA_TEST_MODEL", "olmo-3")
```
2. Replace all 7 hard-coded `llm="ollama/olmo-3"` with `llm=OLLAMA_MODEL`.
3. Remove `verbose=True` from all 7 `Agent(...)` calls. Where verbosity is wanted: `output="verbose"`.
4. Move `force_tool_usage="always"` and `max_tool_repairs=3` into an `llm` dict.
5. Add the CI-minimum test as a new first class — **the only assertion in the file with a measured
6/6 pass rate**:
```python
class TestOllamaCIMinimum:
"""The minimum contract a local model must satisfy: a tool call round-trips.
Deliberately narrow. Measured on qwen3:0.6b: this exact configuration
(forced tool usage, temperature 0, single arithmetic step) passed 6/6.
The default configuration passed 3/4 and multi-step prompts were worse --
those live in TestOllamaToolCallingLive, which CI runs informationally.
"""
def test_tool_call_round_trips(self, ollama_available):
from praisonaiagents import Agent
invocations = []
def add(a: int, b: int) -> int:
"""Add two integers together.
Args:
a: First number to add
b: Second number to add
"""
invocations.append((a, b))
return a + b
agent = Agent(
name="CI Calculator",
llm={"model": OLLAMA_MODEL, "force_tool_usage": "always", "temperature": 0},
tools=[add],
)
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
# 1. the model actually called our function, with the right arguments
assert invocations == [(17, 25)], f"tool was not invoked: {invocations!r}"
# 2. the tool's return value made it back into the model's answer
assert "42" in str(result), f"tool result did not round-trip: {result!r}"
```
Reuse the existing `ollama_available` fixture (lines 48–53) — it already probes
`http://localhost:11434/api/tags` and skips cleanly. Do not add a second probe.
### Why both assertions are needed
`qwen3:0.6b` answers `17 + 25 = 42` **correctly with no tools at all** (measured, 1.2 s,
`tools=[]`). So a bare `assert "42" in result` is satisfied by a model that ignores tools entirely.
The `invocations == [(17, 25)]` half is what proves the tool call was emitted, parsed and
dispatched with the right arguments; the `"42"` half proves the return value was fed back and
reached the final answer. Anything beyond this — multi-step chains, repair loops, resisting
distraction — is measured-flaky on a 0.6B model and belongs in the informational step.
---
## 5. The YAML
Insert after `extended-cohere`'s `Skip notice` step (ends line 760), before the `legacy-unit`
banner. Key fragments:
```yaml
extended-ollama:
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Bump deliberately; the cache key and what we test both follow this.
OLLAMA_VERSION: v0.33.2
OLLAMA_TEST_MODEL: qwen3:0.6b
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache Ollama model blobs
uses: actions/cache@v4
with:
path: ~/.ollama/models
# No restore-keys: a partial hit from a different model would
# silently test something other than OLLAMA_TEST_MODEL.
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
- name: Install Ollama
shell: bash
run: |
set -euo pipefail
curl -fsSL -o /tmp/ollama.tgz \
"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz"
sudo tar -C /usr -xzf /tmp/ollama.tgz
ollama --version
- name: Start Ollama and pull model
shell: bash
run: |
set -euo pipefail
ollama serve > /tmp/ollama-serve.log 2>&1 &
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
echo "Ollama is up after ${i}s"; break
fi
sleep 1
done
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
ollama pull "$OLLAMA_TEST_MODEL"
ollama list
- name: Install dependencies
run: |
pip install --upgrade pip
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
- name: Run Ollama minimum contract (required)
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
shell: bash
run: |
set -euo pipefail
cd src/praisonai
# No "|| echo" here on purpose: no secret is involved, so a failure
# here is a real regression in local-model tool calling.
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-m "provider_ollama" -v --tb=short --timeout=120
- name: Run remaining Ollama live tests (informational)
if: always()
env: { ...same... }
run: |
cd src/praisonai
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
-m "provider_ollama" \
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short --timeout=180 \
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
- name: Ollama server log
if: failure()
run: tail -100 /tmp/ollama-serve.log
```
And in `test-summary` (line 838): extend `needs: [smoke, main, extended-ollama]` and add the
summary rows. `test-summary` is already `if: always()`, so a skipped `extended-ollama` on PRs
reports `skipped` rather than blocking.
---
## 6. Verification and acceptance
```bash
ollama pull qwen3:0.6b
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
# Must be 6/6 before this ships.
for i in $(seq 1 6); do
PRAISONAI_TEST_OLLAMA=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_LIVE_TESTS=1 \
PRAISONAI_TEST_PROVIDERS=ollama PRAISONAI_OLLAMA_TEST_MODEL=qwen3:0.6b \
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-o addopts="" -q --timeout=120 2>&1 | tail -1
done
# The gate still holds with the env var absent:
python -m pytest tests/integration/test_ollama_tool_calling_live.py -o addopts="" -q --collect-only
# expect: collected, all skipped by the module pytestmark
# Nothing leaked onto the PR path:
grep -n "extended-ollama" -A3 .github/workflows/test-optimized.yml | grep "if:"
python -c "import yaml; yaml.safe_load(open('.github/workflows/test-optimized.yml')); print('yaml ok')"
# Then: gh workflow run "Optimized Test Suite" -f tier=extended
```
**Acceptance gate:** a manual `workflow_dispatch` with `tier=extended` shows `extended-ollama`
**green**, with "Run Ollama minimum contract (required)" reporting `1 passed` — **and** the same
workflow triggered by a pull request shows `extended-ollama` **skipped**.
**PR title:** `ci: add nightly live-Ollama job exercising a real local model`
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,196 +0,0 @@
# Local Model Layer — implementation ledger
**Status:** every order below has an open PR. See §4.
**Owner workstream:** local model runtimes (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`)
**Created:** 2026-09-03
---
## 0. How to use this document
This directory is a **work-order ledger**. It exists so that any agent — including one
with no memory of the conversation that produced it — can pick up exactly one unit of
work and execute it correctly, in isolation, without re-deriving the analysis.
**If you are an agent starting work here, do this in order:**
1. Read [`00-ground-truth.md`](00-ground-truth.md). It records what was measured, how, and
when. Do not trust a claim in any work order that contradicts it; re-measure instead.
2. Read §2 (Invariants) and §3 (File ownership) of this file. §3 is what stops two agents
corrupting each other's work.
3. Open the single work order you were assigned. Execute only that one.
4. Update the status row in §4 of this file in the same commit as your change. That row is
the only shared file content; keep the edit to one line to avoid conflicts.
**Do not** expand scope, batch two work orders into one PR, or "fix while you're in there".
Each order is independently revertible by design and that property is worth more than
convenience.
> **Two corrections this ledger has already had to make.** Both were caught by measuring rather
> than reasoning, and both would have sent an agent down the wrong path.
>
> **The D7 root cause is not `provider_ollama`.** The audit blamed the per-file `provider_ollama`
> tag. Measurement (`04-test-gating.md` §2) shows `not network` deselects all seven tests **on its
> own**, because the plugin implies `network` from *any* provider marker and the file also matches
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
> That is why order `04` touches no YAML at all — the fix is marker-side.
> Its first draft told an agent to "wire
> the twelve dead adapter methods". Measurement (`06-adapter-revival.md` §1) says the opposite:
> **12 of 20 items should be deleted, only 3 wired.** Six of the dead methods have no inline
> equivalent anywhere — they are speculative API, and an agent told to "wire them up" would
> invent an implementation that no test could ever have failed against. Read the decision table
> before touching `llm/adapters/`; do not act on the summary in §1 of this file.
---
## 1. Why this work exists
Every local runtime worth supporting already speaks `POST /v1/chat/completions`, and
`litellm` already handles that transport. So the problem is not calling local models — the
SDK can already do that. The problems are:
- **Discovery.** A user with a running daemon still hand-types a URL, a port and an exact
model tag; getting any one wrong produces `OPENAI_API_KEY environment variable is required`.
- **Capability.** "Does this model support tools?" is answerable by asking the server
(`/api/show`, `/props`, `/server_info`). The SDK asks nothing and guesses from a name prefix.
- **Quirks.** Local stacks fail silently with HTTP 200. The sharpest: sending `format` and
`tools` together to Ollama suppresses the tool call and the model fabricates an answer.
The layer being built is therefore a **resolver, not a transport**: it returns data about
what is running and what it will get wrong. Compensating behaviour stays in
`llm/adapters/`, which already declares methods for exactly this purpose.
---
## 2. Invariants — never break these
These come from `src/praisonai-agents/AGENTS.md` §4.6 plus the structural review in
`00-ground-truth.md`. Every work order inherits them; none may waive them.
| # | Invariant | Why it binds this work specifically |
|---|-----------|--------------------------------------|
| I1 | **Backward compatible.** Public API changes need a deprecation cycle. | Several fixes change which code path a given input takes. Each order carries an explicit list of inputs whose behaviour must not change. |
| I2 | **Lazy imports.** Optional deps never imported at module level. | Discovery must not import `litellm` or any runtime SDK at import time. |
| I3 | **Safe defaults.** New features are opt-in. | Discovery must not fire unless the model spec is `"local"` or unset. Never probe the network on a fully-specified config. |
| I4 | **Deterministic tests.** No dependence on timing or external state. | Every test must pass with **no server running**. Live-server tests go behind an explicit marker and never run in the default suite. |
| I5 | **Protocol-driven core.** No heavy implementations in core. | `local/` returns dataclasses. It performs no chat calls, no retry, no request mutation. |
| I6 | **`local/` is a dependency sink.** | It may import the **standard library only** — nothing from `praisonaiagents`, and no third-party distribution (`httpx`/`requests`/`aiohttp` are forbidden; use `urllib.request`). Enforced by a test. See §5 and `07` §3. |
| I7 | **No second abstraction.** | `llm/adapters/` is the extension point. Do not add a parallel quirk/adapter registry beside it. |
| I8 | **No new public knobs without a live consumer.** | Root `AGENTS.md`: do not add params, modules or exports that merely duplicate existing behaviour. |
### I6 in detail — the import rule
An AST pass over `praisonaiagents/` (595 files, 238,936 lines) found **65 subpackages, 212
directed edges and 26 mutually-importing subpackage pairs**. `llm/` already imports upward
into `agent/`, and 15 subpackages import `llm/` back. Consequently a shared helper placed in
`llm/` **cannot** be adopted by `memory/` or `eval/` without deepening the existing cycles.
`local/` is therefore specified as a leaf. That single property is what allows the 21
modules which currently bypass the LLM layer to adopt it later. Treat any import of
`praisonaiagents.*` from inside `local/` as a build break, not a style issue.
The HTTP client is stdlib `urllib.request`, **not** `httpx`. An earlier draft of this ledger
permitted `httpx` on the grounds that it is a transitive dependency of `openai>=2.0.0`. That
was reversed in `07` §3: the edge is undeclared in `pyproject.toml`, so a future `openai`
release that drops `httpx` would silently break the sink — precisely the class of failure this
package exists to catalogue. The boundary test in §5 hard-forbids `httpx`/`requests`/`aiohttp`
so the decision cannot drift.
---
## 3. File ownership — collision avoidance
Two work orders that touch the same file **must not run concurrently**. This table is the
authority. Before starting, check that no in-flight PR owns your files.
| Work order | Owns (may edit) | Must not edit |
|---|---|---|
| `01-d1-default-model` | `agent/agent.py` (branch chain only) | `llm/llm.py`, `llm/adapters/` |
| `02-d3-provider-detection` | `llm/llm.py` (`_detect_provider`, `_is_ollama_provider` only) | `agent/agent.py`, `llm/adapters/` |
| `03-d2-base-url-routing` | `agent/agent.py` (base_url branch), root `README.md` | `llm/llm.py`, `llm/adapters/` |
| `04-test-gating` | `tests/_pytest_plugins/test_gating.py`, both `pytest.ini`, `tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`, `tests/unit/cli/test_test_command.py` | any `praisonaiagents/` source; **`.github/workflows/**` — the `-m` expressions are not the bug** |
| `05-live-ci-job` | `.github/workflows/test-optimized.yml`, `tests/integration/test_ollama_tool_calling_live.py` | any `praisonaiagents/` source; `test_gating.py` |
| `06-adapter-revival` | `llm/adapters/__init__.py`, `llm/llm.py` (3 response paths) | `agent/agent.py` |
| `07-local-package` | `praisonaiagents/local/**` (new), `src/praisonai/tests/unit/llm/local/**` (new), `pyproject.toml` extras | everything else |
| `08-reachability` | `memory/`, `eval/`, `context/`, `lite/`, `telemetry/` | `llm/`, `agent/` |
### Enforced serialisation
- **`01` then `03`** — both edit `agent/agent.py`. Land `01` first.
- **`02` then `06`** — both edit `llm/llm.py`. Land `02` first; it is far smaller.
- **`04` then `05`** — before `04`, `-m provider_ollama` selects 32 tests across 5 files (only 7
are Ollama tests), so `05` has no usable selector and is unbuildable.
- **`04` and `07` are independent** of orders 01/02/03/06 and may run in parallel with them.
- **`08` requires `07`** to have landed, because it consumes `local/`.
---
## 4. Status
Update your row in the same commit as your change. One line only.
Additional PRs not in the original order list, found while executing it:
D4 (local route prefixes) #4798, D5 (local embedding dimensions) #4802,
D6 (registry accepts local providers) #4800.
**Three claims in this ledger were corrected by executing it.** Each is marked
inline where it appears, but they are collected here because a reader who trusts
the summary would otherwise carry the wrong version:
1. *"Wire the twelve dead adapter methods."* Measurement said the opposite:
**12 of 20 items should be deleted, only 3 wired** (`06` §1). Six had no
inline equivalent at all — an agent told to wire them would have invented
behaviour no test could fail against.
2. *"`provider_ollama` deselects the mocked base_url suite."* It does not.
**`not network` deselects it on its own**, because the plugin implies
`network` from any provider marker (`04` §1). Editing the workflow `-m`
strings would have changed nothing.
3. *"Running fully locally is unachievable, not merely unconfigured."* Not so:
litellm and the openai SDK both honour `OPENAI_BASE_URL`, so the bypass sites
**do** reach a local server. The blocker is the hardcoded model name they then
ask for (`00` §5, `08`).
| Order | Title | Branch | PR | Status |
|---|---|---|---|---|
| 01 | D1 — provider-prefixed default model never reaches litellm | `fix/d1-default-provider-model` | #4795 | PR open |
| 02 | D3 — provider over-detection from base_url | `fix/d3-provider-detection` | #4797 | PR open |
| 03 | D2 — README recipe + local-endpoint diagnostic | `fix/d2-base-url-routing` | #4799 | PR open |
| 04 | D7 — per-file provider marker deselects unrelated tests | `fix/d7-per-test-provider-markers` | #4801 | PR open |
| 05 | Live local-model CI job | `feat/ci-ollama-live-job` | #4803 | PR open |
| 06 | A1/A2 — make the provider adapter load-bearing (6 sub-steps) | 6 branches, stacked | #4804 #4806 #4808 #4809 #4810 #4811 | **complete — KNOWN_DEAD empty** |
| 07 | `praisonaiagents/local/` — discovery and capability resolver | `feat/local-model-resolver` | #4807 | PR open |
| 08 | A3 — auxiliary-model fallback configurable everywhere | `fix/reachability-local-endpoint` | #4812 | PR open (model half; parameter half deferred) |
---
## 5. The two tests that keep this work honest
Both are cheap, both are anti-regression, and both should land with their respective orders.
**Import boundary (`07`).** Asserts `local/` imports nothing from `praisonaiagents`. Without
it, I6 decays silently and the leaf property — the whole reason the package can be adopted
by `memory/` and `eval/` — is lost.
**Adapter reachability (`06`).** Asserts every method on `DefaultAdapter` has at least one
call site. This is the test whose absence allowed 11 of 17 adapter methods to become dead code
while `llm.py` grew 145 hand-written Ollama references. Two limits, both real: it cannot detect
an *unreachable* call site (proven by `should_skip_streaming_with_tools`, which a caller would
have satisfied while the branch stayed dead), and **one call site satisfies it — so it does not
detect the three-path divergence at all.** A floor, not a ceiling.
---
## 6. Deliberately out of scope
Named here so that deferring them is a decision rather than an oversight. Do not let a work
order quietly grow into one of these.
| Deferred | Measured size | Why not now |
|---|---|---|
| Consolidating `LLM` and `OpenAIClient` behind one protocol | 7,160 + 2,691 lines, selected by one boolean at 6 sites | Neither is a superset of the other; the merge is larger than every order in this ledger combined. |
| Decomposing `LLM.get_response` | 1,675 lines, 194 branch/loop/try nodes | Highest-risk file in the package. Order `06` must edit it surgically without reflowing it. |
| Removing the sync/async divergence | ~1,628 duplicated lines across 10 pairs; `get_response` vs `_async` only 22.3% similar | Requires the consolidation above to be worth doing. |
The one obligation this ledger accepts toward them: **do not make them worse.** No order may
add a fifth copy of any compensation, and no order may add new logic inside `get_response`
that is not a delegation to an adapter.
@@ -92,6 +92,20 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
# Thread-safe cache for lazy-loaded values
_lazy_cache = {}
# Backward compatibility: _get_lazy_cache function for tests
import threading
_lazy_cache_local = threading.local()
def _get_lazy_cache():
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
Note: This is kept for backward compatibility with tests.
The main lazy loading now uses the centralized _lazy.py utility.
"""
if not hasattr(_lazy_cache_local, 'cache'):
_lazy_cache_local.cache = {}
return _lazy_cache_local.cache
# ============================================================================
# LAZY IMPORT MAPPING
# ============================================================================
@@ -129,16 +143,9 @@ _LAZY_IMPORTS = {
'has_toolset': ('praisonaiagents.toolsets', 'has_toolset'),
# Model-family-aware harness (base prompt + edit-format selection)
'HarnessProfile': ('praisonaiagents.model_harness.profiles', 'HarnessProfile'),
'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'),
'HarnessProfile': ('praisonaiagents.model_harness', 'HarnessProfile'),
'resolve_harness': ('praisonaiagents.model_harness', 'resolve_harness'),
'register_profile': ('praisonaiagents.model_harness', 'register_profile'),
# Run outcomes - typed validation and agent execution results
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
@@ -227,7 +234,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'),
@@ -252,7 +258,6 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RunOutcome': ('praisonaiagents.agent.run_outcome', 'RunOutcome'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
@@ -298,10 +303,9 @@ _LAZY_IMPORTS = {
'CodeAgent': ('praisonaiagents.agent.code_agent', 'CodeAgent'),
'CodeConfig': ('praisonaiagents.agent.code_agent', 'CodeConfig'),
# AgentTeam (primary) / AgentManager, PraisonAIAgents (aliases)
# AgentTeam (primary) / AgentManager (alias)
'AgentTeam': ('praisonaiagents.agents.agents', 'AgentTeam'),
'AgentManager': ('praisonaiagents.agents.agents', 'AgentManager'), # Silent alias
'PraisonAIAgents': ('praisonaiagents.agents.agents', 'PraisonAIAgents'), # Silent alias (pre-1.0 name)
# Note: 'Agents' is handled by _custom_handler for deprecation warning
'Task': ('praisonaiagents.task.task', 'Task'),
'AutoAgents': ('praisonaiagents.agents.autoagents', 'AutoAgents'),
@@ -420,7 +424,6 @@ _LAZY_IMPORTS = {
'WebConfig': ('praisonaiagents.config.feature_configs', 'WebConfig'),
'OutputConfig': ('praisonaiagents.config.feature_configs', 'OutputConfig'),
'ExecutionConfig': ('praisonaiagents.config.feature_configs', 'ExecutionConfig'),
'PreCompactionMemoryFlushConfig': ('praisonaiagents.config.feature_configs', 'PreCompactionMemoryFlushConfig'),
'TemplateConfig': ('praisonaiagents.config.feature_configs', 'TemplateConfig'),
'CachingConfig': ('praisonaiagents.config.feature_configs', 'CachingConfig'),
'HooksConfig': ('praisonaiagents.config.feature_configs', 'HooksConfig'),
@@ -835,11 +838,9 @@ __all__ = [
# Core classes - the essentials
'Agent',
'RunOutcome',
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
'PraisonAIAgents', # Silent alias for AgentTeam (pre-1.0 name)
'Agents', # Deprecated alias for AgentTeam (emits warning)
'Task',
@@ -28,11 +28,8 @@ from typing import Any, Callable, Dict, Optional, Tuple
_logger = logging.getLogger(__name__)
# Global cache lock for thread-safe access.
# Reentrant so that a lazy import which triggers another lazy import on the
# same thread (e.g. a package attribute that re-exports from a submodule via
# its own __getattr__) degrades to redundant work instead of self-deadlocking.
_cache_lock = threading.RLock()
# Global cache lock for thread-safe access
_cache_lock = threading.Lock()
# Global module cache
_module_cache: Dict[str, Any] = {}
@@ -4,6 +4,7 @@ This module consolidates all logging configuration in one place to avoid duplica
"""
import os
import json
import logging
from typing import Any, Dict, List, Optional, Union
@@ -195,9 +196,6 @@ class StructuredFormatter(logging.Formatter):
extra = {k: v for k, v in record.extra_data.items() if k not in self._STANDARD_FIELDS}
log_data.update(extra)
# Deferred: json is only needed for opt-in structured logging
import json
return json.dumps(log_data)
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
"The `dict` method is deprecated; use `model_dump` instead",
"Pydantic serializer warnings",
"PydanticSerializationUnexpectedValue",
"fields but got", # litellm/pydantic "Expected N fields but got M"
"Expected", # Catches all "Expected N fields but got M" patterns
"Expected `StreamingChoices` but got `Choices`",
"serialized value may not be as expected",
"Mixing V1 models and V2 models",
@@ -37,15 +37,6 @@ def __getattr__(name):
from .interrupt import InterruptController
_lazy_cache[name] = InterruptController
return InterruptController
if name in ('RunOutcome', 'TerminalReason'):
from . import run_outcome as _run_outcome_module
value = getattr(_run_outcome_module, name)
_lazy_cache[name] = value
return value
if name == 'prompt_prefix_signature':
from .prompt_cache import prompt_prefix_signature
_lazy_cache[name] = prompt_prefix_signature
return prompt_prefix_signature
# Specialized agents - lazy loaded (import rich)
if name == 'ImageAgent':
@@ -215,14 +206,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}")
@@ -232,9 +215,6 @@ __all__ = [
'Heartbeat',
'HeartbeatConfig',
'InterruptController',
'RunOutcome',
'TerminalReason',
'prompt_prefix_signature',
'ImageAgent',
'VideoAgent',
'VideoConfig',
@@ -305,7 +285,4 @@ __all__ = [
'ChatMixin',
'ExecutionMixin',
'MemoryMixin',
# Attachments
'AttachmentError',
'build_attachment_parts',
]
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,7 @@ async capabilities following the AsyncMemoryProtocol.
"""
import asyncio
import logging
from typing import List, Dict, Any, Optional, Union
from praisonaiagents._logging import get_logger
from ..memory.protocols import AsyncMemoryProtocol
@@ -84,36 +85,21 @@ class AsyncMemoryMixin:
if not hasattr(self, '_memory_instance') or self._memory_instance is None:
logger.debug("No memory configured for async search")
return []
# Shared adapters must receive the configured identity explicitly.
# FileMemory is already bound to a user-specific path and accepts these
# as harmless protocol kwargs.
scoped_kwargs = dict(kwargs)
config = getattr(self, "_memory_config", None)
user_id = getattr(config, "user_id", None)
session_id = getattr(config, "session_id", None)
if user_id:
scoped_kwargs["user_id"] = user_id
if session_id:
scoped_kwargs["session_id"] = session_id
metadata_filter = dict(scoped_kwargs.get("metadata_filter") or {})
metadata_filter["session_id"] = session_id
scoped_kwargs["metadata_filter"] = metadata_filter
# Check if memory adapter supports async operations
if isinstance(self._memory_instance, AsyncMemoryProtocol):
try:
if memory_type == "long_term":
return await self._memory_instance.asearch_long_term(query, limit, **scoped_kwargs)
return await self._memory_instance.asearch_long_term(query, limit, **kwargs)
else:
return await self._memory_instance.asearch_short_term(query, limit, **scoped_kwargs)
return await self._memory_instance.asearch_short_term(query, limit, **kwargs)
except Exception as e:
logger.error(f"Error in async memory search: {e}")
return []
else:
# Fallback: run sync memory operations in thread pool
return await self._run_memory_in_thread(
"search", query, memory_type, limit=limit, **scoped_kwargs
"search", query, memory_type, limit=limit, **kwargs
)
async def _run_memory_in_thread(
@@ -230,4 +216,4 @@ class AsyncMemoryMixin:
)
return False
return True
return False
return False
@@ -5,9 +5,8 @@ This module provides dual-lock abstractions that automatically select
the appropriate lock type based on the execution context (sync vs async).
"""
import asyncio
import copy
import threading
from typing import Any
from typing import Any, Optional, Union
from contextlib import contextmanager, asynccontextmanager
from weakref import WeakKeyDictionary
@@ -40,12 +39,6 @@ class DualLock:
"""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."""
result = type(self)()
memo[id(self)] = result
return result
@contextmanager
def sync(self):
@@ -110,33 +103,6 @@ class AsyncSafeState:
def __init__(self, initial_value: Any = None):
self.value = initial_value
self._lock = DualLock()
self._activity_lock = threading.Lock()
self._async_holders = 0
self._copying = False
def __deepcopy__(self, memo):
"""Copy protected state, rejecting overlap with asynchronous access."""
result = type(self).__new__(type(self))
memo[id(self)] = result
with self._activity_lock:
if self._async_holders:
raise RuntimeError(
"Cannot deepcopy AsyncSafeState during asynchronous access"
)
if self._copying:
raise RuntimeError("AsyncSafeState deepcopy is already in progress")
self._copying = True
try:
with self.lock():
result.value = copy.deepcopy(self.value, memo)
finally:
with self._activity_lock:
self._copying = False
result._lock = DualLock()
result._activity_lock = threading.Lock()
result._async_holders = 0
result._copying = False
return result
@contextmanager
def lock(self):
@@ -148,25 +114,7 @@ class AsyncSafeState:
async def async_lock(self):
"""Acquire lock in async context."""
async with self._lock.async_lock():
self._begin_async_access()
try:
yield self.value
finally:
self._end_async_access()
def _begin_async_access(self) -> None:
"""Register async access unless a synchronous copy is in progress."""
with self._activity_lock:
if self._copying:
raise RuntimeError(
"Cannot access AsyncSafeState during synchronous deepcopy"
)
self._async_holders += 1
def _end_async_access(self) -> None:
"""Unregister one active asynchronous accessor."""
with self._activity_lock:
self._async_holders -= 1
yield self.value
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
@@ -182,20 +130,12 @@ class AsyncSafeState:
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
await async_lock.acquire()
try:
self._begin_async_access()
except Exception:
async_lock.release()
raise
return self.value
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
try:
self._end_async_access()
finally:
async_lock.release()
async_lock.release()
return None
def get(self) -> Any:
@@ -204,4 +144,4 @@ class AsyncSafeState:
def is_async_context(self) -> bool:
"""Check if we're in an async context."""
return self._lock.is_async_context()
return self._lock.is_async_context()
@@ -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}}]
@@ -136,8 +136,9 @@ class AudioAgent:
- AudioConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "AudioAgent")
# Handle model= alias
if llm is None and model is not None:
llm = model
self.name = name or "AudioAgent"
self.instructions = instructions
@@ -34,6 +34,7 @@ Usage:
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Set
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -106,11 +107,8 @@ class AutonomyConfig:
track_changes: Whether to track filesystem changes via shadow git.
Defaults to True when level="full_auto", False otherwise.
When enabled, agent.undo()/redo()/diff() become available.
sandbox: Optional SandboxConfig, carried for forward compatibility.
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
is auto-enabled at any autonomy level. To isolate execution, set it
on the agent itself -- Agent(sandbox=...) -- or run the whole
workflow remotely with AgentFlow(run_on="docker").
sandbox: Optional SandboxConfig for execution isolation.
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
Usage::
@@ -217,13 +215,6 @@ class AutonomyConfig:
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
verification_hooks = data.get("verification_hooks")
# Config parity: accept declarative `verification:` entries and map
# `command:`/`file:` shapes onto the two hook classes.
declarative = data.get("verification")
if declarative:
built = _build_verification_hooks(declarative)
verification_hooks = (verification_hooks or []) + built
return cls(
enabled=data.get("enabled", True),
level=level,
@@ -234,7 +225,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=verification_hooks,
verification_hooks=data.get("verification_hooks"),
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
@@ -244,49 +235,6 @@ class AutonomyConfig:
budget_action=data.get("budget_action", "pause"),
)
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
"""Map declarative ``verification:`` config entries onto hook instances.
Each entry is a dict with either a ``command`` key (→
:class:`CommandVerificationHook`) or a ``file`` key (→
:class:`FileCheckHook`). Unknown shapes are skipped.
Example::
verification:
- {name: tests, command: "python -m pytest -q"}
- {name: changelog, file: CHANGELOG.md, non_empty: true}
"""
from ..hooks.verification import CommandVerificationHook, FileCheckHook
hooks: List[Any] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
blocking = entry.get("blocking", True)
if "command" in entry:
hooks.append(CommandVerificationHook(
name=entry.get("name", "command"),
command=entry["command"],
cwd=entry.get("cwd"),
timeout=entry.get("timeout", 60.0),
blocking=blocking,
))
elif "file" in entry:
hooks.append(FileCheckHook(
name=entry.get("name", "file"),
path=entry["file"],
exists=entry.get("exists", True),
non_empty=entry.get("non_empty", False),
contains=entry.get("contains"),
json_field=entry.get("json_field"),
equals=entry.get("equals"),
blocking=blocking,
))
return hooks
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions.
@@ -315,6 +263,17 @@ class AutonomySignal(str, Enum):
)
super().__init_subclass__(**kwargs)
def _warn_autonomy_signal():
"""Emit deprecation warning when AutonomySignal is accessed."""
from ..utils.deprecation import warn_deprecated_param
warn_deprecated_param(
"AutonomySignal",
since="1.0.0",
removal="2.0.0",
alternative="use EscalationSignal from praisonaiagents.escalation.types instead",
stacklevel=4
)
class AutonomyTrigger:
"""Detects signals from prompts for autonomy decisions.
Loaded 100 of 2282 files, more files were not shown because too many files have changed in this diff. Show more