mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
No files matched your search
@@ -0,0 +1,239 @@
|
||||
<!-- wp:paragraph -->
|
||||
<p><strong>v4.6.113</strong> — three Python packages, strict dependency direction, and <strong>C8 complete</strong>: reverse wrapper imports eliminated from <code>praisonai-code</code>. Tap the diagrams; tables hold the rules.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:quote -->
|
||||
<blockquote class="wp-block-quote"><p><strong>One rule (unchanged):</strong> <code>praisonai-code</code> never lists <code>praisonai</code> in <code>pyproject.toml</code>. Wrapper access is <strong>bridge-only</strong> via <code>_wrapper_bridge</code> — zero direct <code>from praisonai</code> imports remain.</p></blockquote>
|
||||
<!-- /wp:quote -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">✅ C8 validation summary</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Plan target</th><th>Before (C7.1)</th><th>After (C8 / v4.6.113)</th><th>Verdict</th></tr></thead><tbody><tr><td>Wrapper import lines</td><td>225</td><td><strong>0</strong></td><td>✅ Exceeded (~40–60 floor)</td></tr><tr><td>Allowlisted files</td><td>47</td><td><strong>0</strong> (empty)</td><td>✅</td></tr><tr><td>Regression baseline</td><td>225</td><td><strong>50</strong> (headroom gate)</td><td>✅</td></tr><tr><td>No PyPI dep on wrapper</td><td>Yes</td><td>Yes</td><td>✅</td></tr><tr><td>Hot path standalone</td><td>Yes</td><td>Yes</td><td>✅</td></tr><tr><td><code>main.py</code> ≤ 300 lines</td><td>7,454 lines</td><td>~7,492 lines</td><td>⏳ Partial — legacy helpers extracted; full decompose deferred</td></tr><tr><td>PyPI splits (bot/frameworks)</td><td>Out of scope</td><td>Out of scope</td><td>—</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p><strong>Shipped:</strong> PR <a href="https://github.com/MervinPraison/PraisonAI/pull/2590">#2590</a> merged · Release <a href="https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.113">v4.6.113</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading {"level":3} -->
|
||||
<h3 class="wp-block-heading">What C8 moved (repatriation)</h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Phase</th><th>What happened</th><th>Status</th></tr></thead><tbody><tr><td><strong>C8.1</strong></td><td><code>_WRAPPER_RESIDENT_COMMANDS</code>, import-gate regex fix, boundary tests</td><td>✅ Done</td></tr><tr><td><strong>C8.2</strong></td><td>Commands → wrapper: langfuse, train, docs, serve bridge, n8n, replay, …</td><td>✅ Done</td></tr><tr><td><strong>C8.3</strong></td><td>Features → wrapper: recipe, templates, deploy, eval, registry, …</td><td>✅ Done</td></tr><tr><td><strong>C8.4</strong></td><td><code>praisonai/cli/legacy/</code> + <code>prompt_dispatch.py</code> in code</td><td>✅ Partial</td></tr><tr><td><strong>C8.5</strong></td><td>Bridge normalisation + <code>TemplateStore</code> / <code>SessionStore</code> / <code>ServeHandler</code> protocols</td><td>✅ Done</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">📦 Three tiers at a glance</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- 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 tier1 ["Tier 1 — Core SDK"]
|
||||
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
|
||||
end
|
||||
subgraph tier2 ["Tier 2 — Terminal CLI"]
|
||||
Code["praisonai-code<br/>run, chat, code, Typer, runtime"]
|
||||
end
|
||||
subgraph tier3 ["Tier 3 — Wrapper"]
|
||||
Wrapper["praisonai<br/>gateway, bots, framework_adapters, repatriated CLI"]
|
||||
end
|
||||
Agents --> Code
|
||||
Agents --> Wrapper
|
||||
Code -.->|"bridge only<br/>0 direct imports"| Wrapper
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
classDef decision fill:#444,color:#fff
|
||||
class Agents agent
|
||||
class Code tool
|
||||
class Wrapper decision
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Tier</th><th>Package</th><th>Owns</th><th>Version (v4.6.113)</th></tr></thead><tbody><tr><td><strong>1</strong></td><td><code>praisonaiagents</code></td><td>Agent, tools, memory, hooks, CLI protocols (C8.5)</td><td>1.6.113</td></tr><tr><td><strong>2</strong></td><td><code>praisonai-code</code></td><td><code>run</code>, <code>chat</code>, <code>code</code>, Typer hot path, bridge</td><td>0.0.11</td></tr><tr><td><strong>3</strong></td><td><code>praisonai</code></td><td>bots, gateway, adapters, repatriated commands/features</td><td>4.6.113</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading {"level":3} -->
|
||||
<h3 class="wp-block-heading">Tier 1 — <code>praisonaiagents</code></h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Module</th><th>What it does</th></tr></thead><tbody><tr><td><code>agent/</code></td><td>Agent class, execution, handoffs</td></tr><tr><td><code>tools/</code></td><td><code>@tool</code>, BaseTool, registry</td></tr><tr><td><code>memory/</code>, <code>hooks/</code>, <code>llm/</code></td><td>Memory, events, model routing</td></tr><tr><td><code>frameworks/</code></td><td>Protocols only — not CrewAI/AutoGen impls</td></tr><tr><td><code>cli/protocols.py</code></td><td><strong>C8.5:</strong> TemplateStore, SessionStore, ServeHandler protocols</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading {"level":3} -->
|
||||
<h3 class="wp-block-heading">Tier 2 — <code>praisonai-code</code></h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Component</th><th>What it does</th></tr></thead><tbody><tr><td><code>run/chat/code</code></td><td>Agentic hot path — standalone, strict gate</td></tr><tr><td><code>cli/app.py</code></td><td>Typer router + <code>_WRAPPER_RESIDENT_COMMANDS</code></td></tr><tr><td><code>_wrapper_bridge.py</code></td><td><strong>Only</strong> path to wrapper modules (string-based lazy import)</td></tr><tr><td><code>cli/commands/serve.py</code></td><td>SDK serve + bridge to wrapper gateway/endpoints</td></tr><tr><td><code>cli/features/doctor/</code></td><td>Health checks; wrapper checks SKIP if standalone</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:heading {"level":3} -->
|
||||
<h3 class="wp-block-heading">Tier 3 — <code>praisonai</code> (now owns repatriated CLI)</h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Component</th><th>What it does</th></tr></thead><tbody><tr><td><code>cli/commands/*</code></td><td>bot, gateway, train, langfuse, docs, n8n, replay, …</td></tr><tr><td><code>cli/features/*</code></td><td>recipe, templates, deploy, eval, serve orchestration</td></tr><tr><td><code>cli/legacy/</code></td><td>inbuilt_tools, framework_run (C8.4)</td></tr><tr><td><code>bots/</code>, <code>gateway/</code></td><td>Multi-platform bot orchestration</td></tr><tr><td><code>framework_adapters/</code></td><td>CrewAI, AutoGen, registry</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">🔀 CLI routing</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- 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 LR
|
||||
User[User] --> Entry{Entry}
|
||||
Entry -->|praisonai-code| Hot["run / chat / code"]
|
||||
Entry -->|praisonai| Router[Typer router]
|
||||
Router --> App[praisonai_code.cli.app]
|
||||
Router --> Legacy[Legacy YAML]
|
||||
App --> Resident{_WRAPPER_RESIDENT?}
|
||||
Resident -->|yes + wrapper| WrapCmd[praisonai.cli.commands.*]
|
||||
Resident -->|no| CodeCmd[praisonai_code.cli.commands.*]
|
||||
Resident -->|no wrapper| Hide[Hidden from help]
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
classDef decision fill:#444,color:#fff
|
||||
class Hot tool
|
||||
class WrapCmd decision
|
||||
class Resident decision
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Command type</th><th>Loaded from</th><th>Standalone?</th></tr></thead><tbody><tr><td><code>run</code>, <code>chat</code>, <code>code</code></td><td><code>praisonai_code</code></td><td>✅</td></tr><tr><td><code>bot</code>, <code>gateway</code>, <code>train</code>, …</td><td><code>praisonai.cli.commands.*</code></td><td>❌ hidden</td></tr><tr><td>Legacy YAML / <code>--framework crewai</code></td><td><code>cli/main.py</code> + bridge</td><td>❌</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:quote -->
|
||||
<blockquote class="wp-block-quote"><p><strong>C8 change:</strong> 30+ commands are <code>_WRAPPER_RESIDENT</code> — implementation lives only in the wrapper; code package routes lazily with no duplicate modules.</p></blockquote>
|
||||
<!-- /wp:quote -->
|
||||
|
||||
<!-- wp:separator -->
|
||||
<hr class="wp-block-separator has-alpha-channel-opacity"/>
|
||||
<!-- /wp:separator -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">🌉 Bridge-only access (post-C8)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- 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"}}}%%
|
||||
sequenceDiagram
|
||||
participant Code as praisonai_code
|
||||
participant Bridge as _wrapper_bridge
|
||||
participant Wrap as praisonai
|
||||
Code->>Bridge: import_wrapper_module("praisonai.cli.features.serve")
|
||||
alt wrapper installed
|
||||
Bridge->>Wrap: importlib.import_module
|
||||
Wrap-->>Code: module handle
|
||||
else standalone
|
||||
Bridge-->>Code: ImportError + pip install praisonai hint
|
||||
end
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Before C8</th><th>After C8</th></tr></thead><tbody><tr><td>225 direct <code>from praisonai</code> lines</td><td><strong>0</strong> — all bridge string imports</td></tr><tr><td>47 allowlisted files</td><td><strong>0</strong> — empty allowlist</td></tr><tr><td>Duplicate command copies in code + wrapper</td><td>Single impl in wrapper; code routes only</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">🛡️ Import gates (CI)</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- 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 TD
|
||||
CI[CI smoke] --> Venv[Standalone venv]
|
||||
Venv --> Hot[Hot-path gate]
|
||||
Venv --> Script[check_c7_imports.sh]
|
||||
Script --> Strict[Strict: run/chat/code/app]
|
||||
Script --> Base["Baseline max 50 lines"]
|
||||
Script --> Allow[Empty allowlist enforced]
|
||||
Venv --> Run[praisonai-code run]
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
class CI agent
|
||||
class Script tool
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Gate</th><th>Check</th><th>Status</th></tr></thead><tbody><tr><td>Hot path</td><td>No module-level wrapper import in main/app/run/chat/code</td><td>✅</td></tr><tr><td>Strict</td><td>Zero wrapper imports in app/run/chat/code</td><td>✅</td></tr><tr><td>Baseline</td><td>≤ <strong>50</strong> direct import lines (current: <strong>0</strong>)</td><td>✅</td></tr><tr><td>Allowlist</td><td>Any offender must be listed (list is empty)</td><td>✅</td></tr><tr><td>Smoke</td><td>Standalone <code>praisonai-code run</code></td><td>✅</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>bash scripts/check_c7_imports.sh
|
||||
pytest src/praisonai/tests/unit/test_c7_1_boundaries.py
|
||||
pytest src/praisonai/tests/unit/test_c5_backward_compat.py</code></pre>
|
||||
<!-- /wp:code -->
|
||||
|
||||
<!-- wp:separator -->
|
||||
<hr class="wp-block-separator has-alpha-channel-opacity"/>
|
||||
<!-- /wp:separator -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2 class="wp-block-heading">🔌 Framework adapters</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- 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 sdk [praisonaiagents]
|
||||
P[frameworks protocols]
|
||||
CP[cli protocols C8.5]
|
||||
end
|
||||
subgraph wrap [praisonai]
|
||||
R[framework_adapters registry]
|
||||
C[CrewAI AutoGen]
|
||||
end
|
||||
subgraph code [praisonai-code]
|
||||
B[_wrapper_bridge]
|
||||
end
|
||||
P -.-> R
|
||||
CP -.-> wrap
|
||||
B -->|lazy| R
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
class P agent
|
||||
class B tool
|
||||
</pre></div>
|
||||
<!-- /wp:merpress/mermaidjs -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Layer</th><th>Location</th></tr></thead><tbody><tr><td>Framework protocols</td><td><code>praisonaiagents/frameworks/</code></td></tr><tr><td>CLI protocols (C8.5)</td><td><code>praisonaiagents/cli/protocols.py</code></td></tr><tr><td>Implementations</td><td><code>praisonai/framework_adapters/</code></td></tr><tr><td>Access from code</td><td><code>_wrapper_bridge</code> only</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">📋 Milestone status</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:table -->
|
||||
<figure class="wp-block-table"><table><thead><tr><th>Milestone</th><th>State</th><th>Release</th></tr></thead><tbody><tr><td>C7 hot path</td><td>✅ Complete</td><td>—</td></tr><tr><td>C7.1 boundaries</td><td>✅ Complete</td><td>v4.6.110</td></tr><tr><td><strong>C8 reverse imports</strong></td><td>✅ Complete</td><td><strong>v4.6.113</strong></td></tr><tr><td>C8.4 full main.py split</td><td>⏳ Deferred</td><td>Future</td></tr><tr><td>PyPI splits (bot / frameworks)</td><td>⏳ Out of scope</td><td>—</td></tr></tbody></table></figure>
|
||||
<!-- /wp:table -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p><a href="https://github.com/MervinPraison/PraisonAI/blob/main/ARCHITECTURE.md">ARCHITECTURE.md</a> · <a href="https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai/tests/C7.1_BOUNDARIES.md">C7.1 boundaries</a> · <a href="https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai/tests/C8_BACKLOG.md">C8 backlog</a> · <a href="https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.113">v4.6.113 release</a> · <a href="https://github.com/MervinPraison/PraisonAI/pull/2590">PR #2590</a></p>
|
||||
<!-- /wp:paragraph -->
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Install PraisonAI monorepo packages
|
||||
description: Install praisonai-agents, praisonai-code, and praisonai wrapper from local paths (mirrors PyPI three-package install order).
|
||||
|
||||
inputs:
|
||||
repo-root:
|
||||
description: Path prefix to monorepo root (e.g. PraisonAI/ for cross-repo checkout)
|
||||
required: false
|
||||
default: ""
|
||||
use-uv:
|
||||
description: Use uv pip instead of pip
|
||||
required: false
|
||||
default: "false"
|
||||
agents-extras:
|
||||
description: Optional extras for praisonaiagents (e.g. .[all] or [all])
|
||||
required: false
|
||||
default: ""
|
||||
wrapper-extras:
|
||||
description: Optional extras for praisonai wrapper (e.g. .[chat,code] or [chat,code])
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install monorepo packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORKSPACE="${GITHUB_WORKSPACE:-$(pwd)}"
|
||||
ROOT="${{ inputs.repo-root }}"
|
||||
if [ -n "$ROOT" ]; then
|
||||
BASE="${WORKSPACE%/}/${ROOT%/}/"
|
||||
else
|
||||
BASE="${WORKSPACE%/}/"
|
||||
fi
|
||||
AGENTS_PKG="${BASE}src/praisonai-agents"
|
||||
CODE_PKG="${BASE}src/praisonai-code"
|
||||
BOT_PKG="${BASE}src/praisonai-bot"
|
||||
WRAPPER_PKG="${BASE}src/praisonai"
|
||||
|
||||
normalise_extras() {
|
||||
local value="${1#.}"
|
||||
if [ -n "$value" ]; then
|
||||
printf '.%s' "$value"
|
||||
else
|
||||
printf '.'
|
||||
fi
|
||||
}
|
||||
|
||||
AGENTS_SUFFIX="$(normalise_extras "${{ inputs.agents-extras }}")"
|
||||
WRAPPER_SUFFIX="$(normalise_extras "${{ inputs.wrapper-extras }}")"
|
||||
|
||||
if [ "${{ inputs.use-uv }}" = "true" ]; then
|
||||
cd "$AGENTS_PKG"
|
||||
uv pip install --system -e "${AGENTS_SUFFIX}"
|
||||
cd "$CODE_PKG"
|
||||
uv pip install --system -e .
|
||||
cd "$BOT_PKG"
|
||||
uv pip install --system -e .
|
||||
cd "$WRAPPER_PKG"
|
||||
uv pip install --system "${WRAPPER_SUFFIX}"
|
||||
else
|
||||
cd "$AGENTS_PKG"
|
||||
pip install -e "${AGENTS_SUFFIX}"
|
||||
cd "$CODE_PKG"
|
||||
pip install -e .
|
||||
cd "$BOT_PKG"
|
||||
pip install -e .
|
||||
cd "$WRAPPER_PKG"
|
||||
pip install -e "${WRAPPER_SUFFIX}"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/ci-failure-claude-selftest.js
|
||||
*/
|
||||
const ciFix = require('./ci-failure-claude.js');
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed += 1;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_2767 = `
|
||||
test-core (cli) UNKNOWN STEP 2026-07-07T17:21:49.8096670Z FAILED (0.0100s) tests/unit/cli/test_typer_cli.py::TestConfigCommand::test_config_list - ValueError: I/O operation on closed file.
|
||||
test-core (cli) UNKNOWN STEP 2026-07-07T17:21:49.8097323Z ============= 1 failed, 1401 passed, 1 skipped in 88.86s (0:01:28) =============
|
||||
test-core (cli) UNKNOWN STEP 2026-07-07T17:21:49.8693341Z ##[error]Process completed with exit code 1.
|
||||
`;
|
||||
|
||||
const parsed = ciFix.parsePytestFailures(LOG_2767);
|
||||
assert('parses pytest failure from log', parsed.length === 1);
|
||||
assert(
|
||||
'extracts test path',
|
||||
parsed[0]?.testId === 'tests/unit/cli/test_typer_cli.py::TestConfigCommand::test_config_list'
|
||||
);
|
||||
assert(
|
||||
'extracts error message',
|
||||
parsed[0]?.error.includes('ValueError: I/O operation on closed file')
|
||||
);
|
||||
|
||||
const comment = ciFix.buildCiFixComment({
|
||||
headSha: 'dfb81bb0ae7f36bf8d66d8f45053464c18c005a3',
|
||||
failedChecks: [
|
||||
{
|
||||
name: 'test-core (cli)',
|
||||
workflow: 'Core Tests',
|
||||
html_url: 'https://github.com/MervinPraison/PraisonAI/actions/runs/28885180173/job/85683605433',
|
||||
},
|
||||
],
|
||||
failureSummaries: [{ jobName: 'test-core (cli)', failures: parsed }],
|
||||
});
|
||||
assert('comment includes @claude', comment.includes('@claude'));
|
||||
assert('comment includes short sha', comment.includes('dfb81bb0'));
|
||||
assert('comment includes test path', comment.includes('test_typer_cli.py::TestConfigCommand::test_config_list'));
|
||||
assert('comment includes job url', comment.includes('85683605433'));
|
||||
assert('comment requires critical review verdict', comment.includes('Critical review first'));
|
||||
assert('comment forbids weakening tests', comment.includes('do not weaken, skip, or delete tests'));
|
||||
assert('comment mentions SDK guardrails', comment.includes('SDK guardrails'));
|
||||
assert('comment asks for legitimacy verdict', comment.includes('legitimate fix'));
|
||||
|
||||
const headSha = 'dfb81bb0ae7f36bf8d66d8f45053464c18c005a3';
|
||||
const commentsWithFix = [
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude CI failed on HEAD `dfb81bb0`. Please fix...',
|
||||
created_at: '2026-07-07T18:00:00Z',
|
||||
},
|
||||
];
|
||||
assert('sha dedup detects existing comment', ciFix.hasCiFixCommentForSha(commentsWithFix, headSha));
|
||||
|
||||
const skipNoFinal = ciFix.shouldSkipCiFix({
|
||||
comments: [],
|
||||
headSha,
|
||||
labels: [],
|
||||
hasFinal: false,
|
||||
claudeInProgress: false,
|
||||
failedChecks: [{ name: 'test-core' }],
|
||||
});
|
||||
assert('skips when final not posted', skipNoFinal.skip && skipNoFinal.reason.includes('final'));
|
||||
|
||||
const skipPending = ciFix.shouldSkipCiFix({
|
||||
comments: [],
|
||||
headSha,
|
||||
labels: [ciFix.CI_FIX_LABEL],
|
||||
hasFinal: true,
|
||||
claudeInProgress: false,
|
||||
failedChecks: [{ name: 'test-core' }],
|
||||
});
|
||||
assert('skips when ci fix pending label', skipPending.skip && skipPending.reason.includes('pending'));
|
||||
|
||||
const failedRuns = mergeGate.listFailedChecksOnSha([
|
||||
{ status: 'completed', conclusion: 'success', name: 'smoke' },
|
||||
{ status: 'completed', conclusion: 'failure', name: 'test-core (cli)' },
|
||||
{ status: 'in_progress', conclusion: null, name: 'pending-job' },
|
||||
]);
|
||||
assert('listFailedChecksOnSha finds failures only', failedRuns.length === 1);
|
||||
assert('failed check name', failedRuns[0].name === 'test-core (cli)');
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Post @claude comments with CI failure details for internal PRs.
|
||||
* @see .github/workflows/ci-failure-claude.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
|
||||
const CI_FIX_LABEL = 'claude-ci-fix-pending';
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = mergeGate.AUTO_ACTORS;
|
||||
const MAX_FAILURES = 15;
|
||||
const MAX_JOBS_TO_FETCH = 5;
|
||||
|
||||
function shortSha(headSha) {
|
||||
return (headSha || '').slice(0, 8);
|
||||
}
|
||||
|
||||
function ciFixShaMarker(headSha) {
|
||||
return `ci failed on head \`${shortSha(headSha)}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isCiFixComment(comment) {
|
||||
const body = (comment.body || '').toLowerCase();
|
||||
return body.includes('@claude') && body.includes('ci failed on head');
|
||||
}
|
||||
|
||||
function hasCiFixCommentForSha(comments, headSha) {
|
||||
const marker = shortSha(headSha).toLowerCase();
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!isCiFixComment(c)) return false;
|
||||
return (c.body || '').toLowerCase().includes(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function hasRecentCiFixComment(comments, headSha) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
const marker = shortSha(headSha).toLowerCase();
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!isCiFixComment(c)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (body.includes(marker)) return true;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
|
||||
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
|
||||
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
|
||||
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
|
||||
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
|
||||
if (hasCiFixCommentForSha(comments, headSha)) {
|
||||
return { skip: true, reason: 'already commented for this sha' };
|
||||
}
|
||||
if (hasRecentCiFixComment(comments, headSha)) {
|
||||
return { skip: true, reason: 'recent ci fix comment cooldown' };
|
||||
}
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
function parsePytestFailures(logText) {
|
||||
if (!logText) return [];
|
||||
const lines = logText.split('\n');
|
||||
const failures = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const pytestMatch = line.match(
|
||||
/FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i
|
||||
);
|
||||
if (pytestMatch) {
|
||||
const testId = pytestMatch[1];
|
||||
if (!seen.has(testId)) {
|
||||
seen.add(testId);
|
||||
failures.push({ testId, error: pytestMatch[2].trim() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const errorMatch = line.match(/##\[error\](.+)/);
|
||||
if (errorMatch && /tests\//.test(errorMatch[1])) {
|
||||
const msg = errorMatch[1].trim();
|
||||
if (!seen.has(msg)) {
|
||||
seen.add(msg);
|
||||
failures.push({ testId: msg, error: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) {
|
||||
try {
|
||||
const response = await github.rest.actions.downloadJobLogsForWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
job_id: jobId,
|
||||
});
|
||||
let logText = typeof response.data === 'string' ? response.data : String(response.data || '');
|
||||
if (logText.length > 100000) {
|
||||
logText = logText.slice(-100000);
|
||||
}
|
||||
return { jobName, jobId, failures: parsePytestFailures(logText) };
|
||||
} catch (err) {
|
||||
return { jobName, jobId, failures: [], error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function mapFailedChecks(runs) {
|
||||
return runs.map((run) => ({
|
||||
name: run.name,
|
||||
id: run.id,
|
||||
html_url: run.html_url || run.details_url,
|
||||
workflow: run.app?.slug || run.check_suite?.app?.slug || run.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
|
||||
const parts = [
|
||||
`@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`,
|
||||
'',
|
||||
'## Failed checks',
|
||||
];
|
||||
|
||||
for (const check of failedChecks) {
|
||||
parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`);
|
||||
}
|
||||
|
||||
parts.push('', '## Failures (extracted)');
|
||||
let idx = 0;
|
||||
for (const summary of failureSummaries) {
|
||||
for (const failure of summary.failures) {
|
||||
idx += 1;
|
||||
if (idx > MAX_FAILURES) break;
|
||||
parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``);
|
||||
parts.push(` - Job: \`${summary.jobName}\``);
|
||||
}
|
||||
if (idx >= MAX_FAILURES) break;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
parts.push('_(Could not extract pytest details — see job logs above.)_');
|
||||
}
|
||||
|
||||
const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId;
|
||||
parts.push(
|
||||
'',
|
||||
'## Critical review first',
|
||||
'Before changing code or tests, decide **which side is wrong**:',
|
||||
'1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve SDK guarantees; update tests only when behaviour intentionally changed and document why.',
|
||||
'2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.',
|
||||
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
|
||||
'',
|
||||
'**SDK guardrails (AGENTS.md):**',
|
||||
'- Do not disturb core SDK contracts to accommodate a wrapper/feature change.',
|
||||
'- Tests must continue to guard backward compatibility and hot-path behaviour — passing CI by lowering test standards is not acceptable.',
|
||||
'- If the feature does not genuinely add SDK value, recommend reverting or narrowing scope instead of patching around failures.',
|
||||
'',
|
||||
'## What to do',
|
||||
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
|
||||
'2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
|
||||
exampleTest
|
||||
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related SDK tests touched by the PR.`
|
||||
: '3. Run failing tests locally with targeted pytest, plus any related SDK tests touched by the PR.',
|
||||
'4. Push to this branch and comment: files changed, review verdict, and why tests still protect SDK behaviour.',
|
||||
);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) {
|
||||
if (!labels.includes(CI_FIX_LABEL)) return false;
|
||||
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
|
||||
if (!ciGreen) return false;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: CI_FIX_LABEL,
|
||||
});
|
||||
core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) {
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const pr = ctx.pr;
|
||||
|
||||
if (pr.state !== 'open') return { skipped: true, reason: 'not open' };
|
||||
if (pr.draft) return { skipped: true, reason: 'draft' };
|
||||
|
||||
const headRepo = pr.head.repo?.full_name;
|
||||
if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) {
|
||||
return { skipped: true, reason: 'fork without maintainer edits' };
|
||||
}
|
||||
|
||||
const headSha = pr.head.sha;
|
||||
const labels = ctx.labels;
|
||||
|
||||
if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) {
|
||||
return { skipped: true, reason: 'ci green, label cleared' };
|
||||
}
|
||||
|
||||
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
|
||||
if (ciGreen) return { skipped: true, reason: 'ci green' };
|
||||
|
||||
const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha);
|
||||
const failedRuns = mergeGate.listFailedChecksOnSha(runs);
|
||||
if (failedRuns.length === 0) {
|
||||
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
|
||||
}
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
|
||||
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
|
||||
github, owner, repo, prNumber
|
||||
);
|
||||
const skip = shouldSkipCiFix({
|
||||
comments: ctx.comments,
|
||||
headSha,
|
||||
labels,
|
||||
hasFinal,
|
||||
claudeInProgress,
|
||||
failedChecks: failedRuns,
|
||||
});
|
||||
if (skip.skip) return { skipped: true, reason: skip.reason };
|
||||
|
||||
const failedChecks = mapFailedChecks(failedRuns);
|
||||
const failureSummaries = [];
|
||||
for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) {
|
||||
failureSummaries.push(
|
||||
await fetchJobFailureSummary(github, owner, repo, check.id, check.name)
|
||||
);
|
||||
}
|
||||
|
||||
const body = buildCiFixComment({ headSha, failedChecks, failureSummaries });
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [CI_FIX_LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`);
|
||||
return { triggered: true, headSha: shortSha(headSha) };
|
||||
}
|
||||
|
||||
async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) {
|
||||
if (workflowRun.conclusion !== 'failure') {
|
||||
return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` };
|
||||
}
|
||||
const allowed = ['Core Tests', 'Optimized Test Suite'];
|
||||
if (!allowed.includes(workflowRun.name)) {
|
||||
return { skipped: true, reason: `workflow ${workflowRun.name}` };
|
||||
}
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(
|
||||
github, owner, repo, workflowRun
|
||||
);
|
||||
if (!prNumber) return { skipped: true, reason: 'no linked PR' };
|
||||
return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CI_FIX_LABEL,
|
||||
COOLDOWN_MS,
|
||||
ciFixShaMarker,
|
||||
isCiFixComment,
|
||||
hasCiFixCommentForSha,
|
||||
hasRecentCiFixComment,
|
||||
shouldSkipCiFix,
|
||||
parsePytestFailures,
|
||||
fetchJobFailureSummary,
|
||||
buildCiFixComment,
|
||||
maybeClearCiFixLabel,
|
||||
maybeTriggerCiFixClaude,
|
||||
processWorkflowRunFailure,
|
||||
};
|
||||
@@ -27,6 +27,53 @@ const withRecovery = [
|
||||
];
|
||||
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withClaudeReply = [
|
||||
...finals,
|
||||
{
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
|
||||
created_at: '2026-06-12T08:30:00Z',
|
||||
},
|
||||
];
|
||||
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
|
||||
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
|
||||
|
||||
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
|
||||
|
||||
// Stale-FINAL recovery guards (PR #2560 push loop)
|
||||
const nowMs = Date.now();
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
const firstFinal = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: iso(nowMs - 20 * 60 * 1000),
|
||||
};
|
||||
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal],
|
||||
iso(nowMs - 16 * 60 * 1000)
|
||||
);
|
||||
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
|
||||
|
||||
const secondFinal = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: iso(nowMs - 20 * 60 * 1000),
|
||||
};
|
||||
const capped = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal, secondFinal],
|
||||
iso(nowMs - 2 * 60 * 1000)
|
||||
);
|
||||
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
|
||||
|
||||
const botPush = mg.shouldSkipStaleFinalRecovery(
|
||||
[firstFinal],
|
||||
iso(nowMs - 16 * 60 * 1000),
|
||||
'praisonai-triage-agent[bot]'
|
||||
);
|
||||
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
|
||||
|
||||
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
|
||||
|
||||
// Bot CHANGES_REQUESTED then APPROVE
|
||||
const reviews = [
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
|
||||
@@ -48,6 +95,40 @@ assert('verdict after head accepted', mg.findMergeGateVerdict(
|
||||
'2026-06-12T09:00:00Z'
|
||||
) === 'APPROVE');
|
||||
|
||||
const fallbackApprove = [
|
||||
{
|
||||
body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
|
||||
created_at: '2026-06-12T10:00:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'automated fallback APPROVE ignored when Opus required',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
|
||||
);
|
||||
assert(
|
||||
'automated fallback APPROVE still visible without Opus-only filter',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
|
||||
);
|
||||
|
||||
const fallbackBlockNewFormat = [
|
||||
{
|
||||
body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
|
||||
created_at: '2026-06-12T10:00:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'new-format fallback BLOCK ignored when Opus required',
|
||||
mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
|
||||
);
|
||||
assert(
|
||||
'new-format fallback BLOCK detected by marker helper',
|
||||
mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
|
||||
);
|
||||
assert(
|
||||
'findMergeGateVerdict null-safe with explicit null options',
|
||||
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
|
||||
);
|
||||
|
||||
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
|
||||
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
|
||||
|
||||
@@ -102,23 +183,25 @@ assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
'my-branch'
|
||||
));
|
||||
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
|
||||
const conflictNowMs = Date.now();
|
||||
const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
|
||||
const conflictTrigger = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with main. Please rebase',
|
||||
created_at: '2026-06-26T10:30:38Z',
|
||||
created_at: conflictIso(-30 * 60 * 1000),
|
||||
};
|
||||
const rebaseDone = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Rebase complete — PR #2308 onto latest main',
|
||||
created_at: '2026-06-26T10:30:55Z',
|
||||
created_at: conflictIso(-29 * 60 * 1000),
|
||||
};
|
||||
const finalAfterRebase = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:39:43Z',
|
||||
created_at: conflictIso(-20 * 60 * 1000),
|
||||
};
|
||||
const headAfterRebase = '2026-06-26T10:39:25Z';
|
||||
const headAfterRebase = conflictIso(-21 * 60 * 1000);
|
||||
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));
|
||||
|
||||
@@ -7,6 +7,9 @@ const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
|
||||
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
|
||||
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
|
||||
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
|
||||
const STALE_FINAL_MAX_PER_WINDOW = 2;
|
||||
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
|
||||
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
|
||||
const AGENT_PY_MAX_AUTO_LINES = 100;
|
||||
const PR_MAX_AUTO_ADDITIONS = 800;
|
||||
@@ -38,6 +41,9 @@ const BLOCK_LABELS = new Set([
|
||||
'no-auto-merge',
|
||||
'auto-merged-by-gate',
|
||||
]);
|
||||
const MERGE_READY_LABEL = 'pipeline/merge-ready';
|
||||
/** Superseded concurrency runs; must not block merge when real tests passed. */
|
||||
const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
|
||||
const BOT_REVIEWER_PATTERNS = [
|
||||
'coderabbit',
|
||||
'qodo',
|
||||
@@ -64,6 +70,12 @@ function isClaudeTriggerNoise(c) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClaudeFinalReplyComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage')) return false;
|
||||
return (c.body || '').includes('Claude finished');
|
||||
}
|
||||
|
||||
function hasRecentClaudeTrigger(comments, minutes = 35) {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
return comments.some((c) => {
|
||||
@@ -172,6 +184,11 @@ function isStaleFinalAfterPush(comments, headPushedAt) {
|
||||
);
|
||||
const finalTime = new Date(latestFinal.created_at).getTime();
|
||||
if (headTime <= finalTime + 60000) return false;
|
||||
const claudeRepliedAfterFinal = comments.some((c) => {
|
||||
if (!isClaudeFinalReplyComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() >= finalTime - 60000;
|
||||
});
|
||||
if (claudeRepliedAfterFinal) return false;
|
||||
const claudeSinceHead = comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
@@ -194,6 +211,55 @@ function shouldSkipFinalRecovery(comments, headPushedAt) {
|
||||
return hasRecentClaudeTrigger(comments, 35);
|
||||
}
|
||||
|
||||
function countFinalTriggersSince(comments, sinceMs) {
|
||||
return comments.filter(
|
||||
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
|
||||
).length;
|
||||
}
|
||||
|
||||
function isClaudeAutomationLogin(login) {
|
||||
const lower = (login || '').toLowerCase();
|
||||
return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
|
||||
}
|
||||
|
||||
function isPushSoonAfterLatestFinal(comments, headPushedAt) {
|
||||
if (!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();
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
if (headTime <= finalTime) return false;
|
||||
return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
|
||||
function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
|
||||
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
|
||||
return { skip: true, reason: 'not stale' };
|
||||
}
|
||||
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
|
||||
return { skip: true, reason: 'head pushed by Claude automation' };
|
||||
}
|
||||
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
|
||||
};
|
||||
}
|
||||
const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
|
||||
const finalsInWindow = countFinalTriggersSince(comments, windowStart);
|
||||
if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
|
||||
};
|
||||
}
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
@@ -242,14 +308,25 @@ async function getMergeState(github, owner, repo, prNumber) {
|
||||
};
|
||||
}
|
||||
|
||||
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
async function listChecksOnSha(github, owner, repo, sha) {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
return (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
}
|
||||
|
||||
function listFailedChecksOnSha(runs) {
|
||||
return (runs || []).filter((run) => {
|
||||
if (run.status !== 'completed') return false;
|
||||
return run.conclusion === 'failure';
|
||||
});
|
||||
}
|
||||
|
||||
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
const runs = await listChecksOnSha(github, owner, repo, sha);
|
||||
if (runs.length === 0) {
|
||||
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
|
||||
return true;
|
||||
@@ -259,7 +336,9 @@ async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
core?.info?.(`Check pending: ${run.name} (${run.status})`);
|
||||
return false;
|
||||
}
|
||||
const ok = ['success', 'neutral', 'skipped'].includes(run.conclusion);
|
||||
const ok =
|
||||
['success', 'neutral', 'skipped'].includes(run.conclusion) ||
|
||||
(run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name));
|
||||
if (!ok) {
|
||||
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
|
||||
return false;
|
||||
@@ -657,10 +736,67 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
|
||||
};
|
||||
}
|
||||
|
||||
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
|
||||
let mergeReadyIssues;
|
||||
if (typeof github.paginate === 'function') {
|
||||
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
labels: MERGE_READY_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
labels: MERGE_READY_LABEL,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const mergeReady = mergeReadyIssues
|
||||
.filter((issue) => issue.pull_request)
|
||||
.map((issue) => issue.number)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (mergeReady.length > 0) {
|
||||
core?.info?.(
|
||||
`Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
|
||||
);
|
||||
return mergeReady;
|
||||
}
|
||||
|
||||
core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`);
|
||||
const prs =
|
||||
typeof github.paginate === 'function'
|
||||
? await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
})
|
||||
: (await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
})).data;
|
||||
return prs.map((pr) => pr.number);
|
||||
}
|
||||
|
||||
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
|
||||
const readyList = [];
|
||||
const skipped = [];
|
||||
for (const num of prNumbers) {
|
||||
if (readyList.length >= maxCandidates) {
|
||||
core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`);
|
||||
break;
|
||||
}
|
||||
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
|
||||
if (result.ready) {
|
||||
readyList.push({ pr_number: num, head_sha: result.headSha });
|
||||
@@ -675,12 +811,20 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
|
||||
};
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null) {
|
||||
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
|
||||
|
||||
function isAutomatedFallbackVerdict(body) {
|
||||
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
|
||||
}
|
||||
|
||||
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 (!(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;
|
||||
if (headTime && created < headTime) return false;
|
||||
@@ -699,6 +843,7 @@ module.exports = {
|
||||
AUTO_ACTORS,
|
||||
isFinalClaudeTriggerComment,
|
||||
isClaudeTriggerNoise,
|
||||
isClaudeFinalReplyComment,
|
||||
hasRecentClaudeTrigger,
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
@@ -709,6 +854,9 @@ module.exports = {
|
||||
hasFinalClaudeReviewTrigger,
|
||||
finalClaudeCompletedOnSha,
|
||||
getMergeState,
|
||||
OPTIONAL_CANCELLED_CHECKS,
|
||||
listChecksOnSha,
|
||||
listFailedChecksOnSha,
|
||||
allChecksGreenOnSha,
|
||||
hasInProgressClaudeAssistant,
|
||||
claudeRunBlocksPr,
|
||||
@@ -736,9 +884,19 @@ module.exports = {
|
||||
isStaleFinalAfterPush,
|
||||
needsStaleFinalRecovery,
|
||||
shouldSkipFinalRecovery,
|
||||
shouldSkipStaleFinalRecovery,
|
||||
isClaudeAutomationLogin,
|
||||
isPushSoonAfterLatestFinal,
|
||||
countFinalTriggersSince,
|
||||
STALE_FINAL_RECOVERY_WINDOW_MS,
|
||||
STALE_FINAL_MAX_PER_WINDOW,
|
||||
FINAL_CLAUDE_REVIEW_BODY,
|
||||
loadPrContext,
|
||||
evaluatePipelineQuiescent,
|
||||
MERGE_READY_LABEL,
|
||||
listPrNumbersForMergeGateScan,
|
||||
selectMergeGateCandidates,
|
||||
isAutomatedFallbackVerdict,
|
||||
AUTOMATED_FALLBACK_MARKER,
|
||||
findMergeGateVerdict,
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
const ciFix = require('./ci-failure-claude.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
@@ -155,6 +156,18 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
|
||||
const ciNotGreen = (evalResult.reasons || []).some((r) =>
|
||||
r.toLowerCase().includes('ci not green')
|
||||
);
|
||||
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
|
||||
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
} else {
|
||||
await ciFix.maybeClearCiFixLabel(
|
||||
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
synced: true,
|
||||
stage,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const coderabbit = {
|
||||
user: { login: 'coderabbitai[bot]' },
|
||||
body: '<!-- summarize by coderabbit.ai --> summary',
|
||||
created_at: '2026-07-02T07:16:00Z',
|
||||
};
|
||||
const greptile = {
|
||||
user: { login: 'greptile-apps[bot]' },
|
||||
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
|
||||
created_at: '2026-07-02T07:19:00Z',
|
||||
};
|
||||
const copilotKick = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@copilot Do a thorough review',
|
||||
created_at: '2026-07-02T07:20:00Z',
|
||||
};
|
||||
const copilotReply = {
|
||||
user: { login: 'copilot-swe-agent' },
|
||||
body: 'Copilot review here',
|
||||
created_at: '2026-07-02T07:22:00Z',
|
||||
};
|
||||
|
||||
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
|
||||
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
|
||||
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
|
||||
assert(
|
||||
'copilot ready after reply',
|
||||
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude not ready before copilot',
|
||||
!chain.claudeFinalReady([coderabbit, greptile]).ready
|
||||
);
|
||||
assert(
|
||||
'claude ready after full chain',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude timeout fallback when copilot triggered but silent',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'no timeout fallback without copilot trigger',
|
||||
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'skipCopilot proceeds after prior reviewers',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
|
||||
);
|
||||
const prev = process.env.REVIEW_CHAIN_SKIP_COPILOT;
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = '1';
|
||||
assert(
|
||||
'REVIEW_CHAIN_SKIP_COPILOT env skips copilot',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], {}).ready
|
||||
);
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = prev;
|
||||
|
||||
const geminiReview = {
|
||||
user: { login: 'gemini-code-assist[bot]' },
|
||||
body: '## Code Review\n\nThis pull request decomposes the large main.py file',
|
||||
submitted_at: '2026-07-03T08:08:00Z',
|
||||
};
|
||||
assert(
|
||||
'gemini pull review counts as prior reviewer',
|
||||
chain.hasGeminiReview([], [geminiReview])
|
||||
);
|
||||
assert(
|
||||
'prior ready with gemini review only (no gemini comment)',
|
||||
chain.priorReviewersReady([coderabbit, greptile], [geminiReview], { optionalWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL).
|
||||
* Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass.
|
||||
* @see .github/workflows/auto-pr-comment.yml
|
||||
*/
|
||||
|
||||
function isSkipCopilot(options = {}) {
|
||||
if (options.skipCopilot === true) return true;
|
||||
if (options.skipCopilot === false) return false;
|
||||
return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1';
|
||||
}
|
||||
|
||||
const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
|
||||
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
|
||||
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
|
||||
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
|
||||
|
||||
function loginOf(item) {
|
||||
return (item?.user?.login || '').toLowerCase();
|
||||
}
|
||||
|
||||
function bodyOf(item) {
|
||||
return item?.body || '';
|
||||
}
|
||||
|
||||
function hasCoderabbitSummary(comments) {
|
||||
return comments.some(
|
||||
(c) =>
|
||||
loginOf(c).includes('coderabbit') &&
|
||||
bodyOf(c).toLowerCase().includes('summarize by coderabbit')
|
||||
);
|
||||
}
|
||||
|
||||
function hasGreptileReview(comments) {
|
||||
return comments.some((c) => {
|
||||
if (!loginOf(c).includes('greptile')) return false;
|
||||
const body = bodyOf(c);
|
||||
return body.includes('Greptile Summary') || body.includes('<h3>Greptile Summary</h3>') || body.length > 150;
|
||||
});
|
||||
}
|
||||
|
||||
function hasQodoReview(comments, reviews = []) {
|
||||
if (comments.some((c) => loginOf(c).includes('qodo'))) return true;
|
||||
return reviews.some((r) => loginOf(r).includes('qodo'));
|
||||
}
|
||||
|
||||
function hasGeminiReview(comments, reviews = []) {
|
||||
if (reviews.some((r) => loginOf(r).includes('gemini'))) return true;
|
||||
return comments.some((c) => {
|
||||
if (loginOf(c).includes('gemini')) return true;
|
||||
const body = bodyOf(c);
|
||||
return (
|
||||
body.includes('Review completed by Gemini CLI') ||
|
||||
body.includes('The fix is ready for review. Please test')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const PRIOR_CHECKS = {
|
||||
coderabbit: (comments) => hasCoderabbitSummary(comments),
|
||||
greptile: (comments) => hasGreptileReview(comments),
|
||||
qodo: (comments, reviews) => hasQodoReview(comments, reviews),
|
||||
gemini: (comments, reviews) => hasGeminiReview(comments, reviews),
|
||||
};
|
||||
|
||||
function priorReviewerStatus(comments, reviews = []) {
|
||||
const status = {};
|
||||
for (const [id, check] of Object.entries(PRIOR_CHECKS)) {
|
||||
status[id] = check(comments, reviews);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function priorReviewersReady(comments, reviews = [], options = {}) {
|
||||
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
|
||||
const prCreatedAt = options.prCreatedAt || null;
|
||||
const status = priorReviewerStatus(comments, reviews);
|
||||
|
||||
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
|
||||
if (missingRequired.length) {
|
||||
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
|
||||
}
|
||||
|
||||
const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]);
|
||||
if (missingOptional.length && prCreatedAt) {
|
||||
const age = Date.now() - new Date(prCreatedAt).getTime();
|
||||
if (age < waitMs) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`,
|
||||
status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: true, reason: '', status };
|
||||
}
|
||||
|
||||
function copilotTriggerComment(comments) {
|
||||
return comments.find(
|
||||
(c) =>
|
||||
bodyOf(c).includes('@copilot') &&
|
||||
COPILOT_TRIGGER_LOGINS.has(c.user?.login) &&
|
||||
!bodyOf(c).toLowerCase().includes('@claude')
|
||||
);
|
||||
}
|
||||
|
||||
function copilotTriggered(comments) {
|
||||
return Boolean(copilotTriggerComment(comments));
|
||||
}
|
||||
|
||||
function copilotReviewReady(comments, reviews = []) {
|
||||
const trigger = copilotTriggerComment(comments);
|
||||
if (!trigger) {
|
||||
return { ready: false, reason: 'copilot not triggered yet' };
|
||||
}
|
||||
const triggerTime = new Date(trigger.created_at).getTime();
|
||||
const copilotComment = comments.some((c) => {
|
||||
if (c === trigger) return false;
|
||||
if (new Date(c.created_at).getTime() < triggerTime) return false;
|
||||
const login = loginOf(c);
|
||||
return (
|
||||
login.includes('copilot') ||
|
||||
c.user?.login === 'Copilot' ||
|
||||
c.user?.login === 'copilot-swe-agent'
|
||||
);
|
||||
});
|
||||
const copilotReview = reviews.some((r) => {
|
||||
if (new Date(r.submitted_at).getTime() < triggerTime) return false;
|
||||
return loginOf(r).includes('copilot');
|
||||
});
|
||||
if (copilotComment || copilotReview) {
|
||||
return { ready: true, reason: '' };
|
||||
}
|
||||
return { ready: false, reason: 'awaiting copilot review' };
|
||||
}
|
||||
|
||||
function isFinalClaudeTriggerComment(c) {
|
||||
const body = bodyOf(c).toLowerCase();
|
||||
if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
}
|
||||
|
||||
function claudeFinalAlreadyTriggered(comments) {
|
||||
return comments.some(isFinalClaudeTriggerComment);
|
||||
}
|
||||
|
||||
function claudeFinalReady(comments, reviews = [], options = {}) {
|
||||
if (claudeFinalAlreadyTriggered(comments)) {
|
||||
return { ready: false, reason: 'claude FINAL already triggered', already: true };
|
||||
}
|
||||
const prior = priorReviewersReady(comments, reviews, options);
|
||||
if (!prior.ready) {
|
||||
return { ready: false, reason: prior.reason };
|
||||
}
|
||||
if (isSkipCopilot(options)) {
|
||||
return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
|
||||
}
|
||||
const copilot = copilotReviewReady(comments, reviews);
|
||||
if (copilot.ready) {
|
||||
return { ready: true, reason: '' };
|
||||
}
|
||||
if (options.allowCopilotTimeout && copilotTriggered(comments)) {
|
||||
return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true };
|
||||
}
|
||||
return { ready: false, reason: copilot.reason };
|
||||
}
|
||||
|
||||
/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */
|
||||
async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const chainOpts = {
|
||||
prCreatedAt: pr.created_at,
|
||||
optionalWaitMs: options.optionalWaitMs ?? 0,
|
||||
};
|
||||
const copilot = options.skipCopilot
|
||||
? { triggered: false, reason: 'skipped' }
|
||||
: await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts);
|
||||
const claude = await maybeTriggerClaudeFinal(
|
||||
github, owner, repo, prNumber, finalBody, core,
|
||||
{ ...options, ...chainOpts }
|
||||
);
|
||||
return { copilot, claude };
|
||||
}
|
||||
|
||||
async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
|
||||
const gate = claudeFinalReady(comments, reviews, {
|
||||
prCreatedAt: pr.created_at,
|
||||
optionalWaitMs: options.optionalWaitMs,
|
||||
allowCopilotTimeout: options.allowCopilotTimeout !== false,
|
||||
});
|
||||
if (!gate.ready) {
|
||||
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
|
||||
return { posted: false, reason: gate.reason };
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: finalBody,
|
||||
});
|
||||
const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : '';
|
||||
core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`);
|
||||
return { posted: true, reason: '' };
|
||||
}
|
||||
|
||||
const COPILOT_REVIEW_BODY =
|
||||
'@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, CodeRabbit, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible';
|
||||
|
||||
async function listCommentsAndReviews(github, owner, repo, prNumber) {
|
||||
let comments;
|
||||
if (typeof github.paginate === 'function') {
|
||||
comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
comments = data;
|
||||
}
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return { comments, reviews };
|
||||
}
|
||||
|
||||
async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) {
|
||||
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
|
||||
if (copilotTriggered(comments)) {
|
||||
core?.info?.(`Copilot already triggered on PR #${prNumber}`);
|
||||
return { triggered: false, reason: 'already_triggered' };
|
||||
}
|
||||
const prior = priorReviewersReady(comments, reviews, options);
|
||||
if (!prior.ready) {
|
||||
core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`);
|
||||
return { triggered: false, reason: prior.reason };
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COPILOT_REVIEW_BODY,
|
||||
});
|
||||
core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`);
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) {
|
||||
const maxAttempts = options.maxAttempts ?? 20;
|
||||
const delayMs = options.delayMs ?? 30000;
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
|
||||
const copilot = copilotReviewReady(comments, reviews);
|
||||
if (copilot.ready) {
|
||||
return { ready: true, reason: '' };
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
return { ready: false, reason: 'copilot timeout' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSkipCopilot,
|
||||
REQUIRED_PRIOR,
|
||||
OPTIONAL_PRIOR,
|
||||
COPILOT_REVIEW_BODY,
|
||||
priorReviewerStatus,
|
||||
priorReviewersReady,
|
||||
copilotTriggered,
|
||||
copilotReviewReady,
|
||||
claudeFinalReady,
|
||||
claudeFinalAlreadyTriggered,
|
||||
isFinalClaudeTriggerComment,
|
||||
maybeTriggerCopilot,
|
||||
maybeTriggerClaudeFinal,
|
||||
advanceReviewChain,
|
||||
pollCopilotResponse,
|
||||
hasGeminiReview,
|
||||
listCommentsAndReviews,
|
||||
};
|
||||
@@ -3,9 +3,18 @@ const rg = require('./release-gate.js');
|
||||
|
||||
assert.strictEqual(rg.bumpPatch('1.6.80'), '1.6.81');
|
||||
assert.strictEqual(rg.bumpPatch('4.6.80'), '4.6.81');
|
||||
assert.strictEqual(rg.bumpPatch('0.0.2'), '0.0.3');
|
||||
|
||||
const versions = rg.readVersionsFromTree();
|
||||
assert.ok(versions.currentAgents);
|
||||
assert.ok(versions.currentCode);
|
||||
assert.ok(versions.currentWrapper);
|
||||
assert.ok(versions.targetCode);
|
||||
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
|
||||
|
||||
(async () => {
|
||||
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
|
||||
console.log('ok: bumpPatch');
|
||||
console.log('ok: readVersionsFromTree');
|
||||
console.log('ok: pypiVersionExists missing version');
|
||||
})();
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents'];
|
||||
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
|
||||
const ACTIVE_RELEASE_STATUSES = new Set([
|
||||
'queued', 'in_progress', 'waiting', 'pending', 'requested',
|
||||
]);
|
||||
@@ -23,6 +23,13 @@ function readVersionsFromTree(root = '.') {
|
||||
);
|
||||
const agentsMatch = agentsToml.match(/^version\s*=\s*"([^"]+)"/m);
|
||||
if (!agentsMatch) throw new Error('Could not read agents version');
|
||||
|
||||
const codeToml = fs.readFileSync(
|
||||
path.join(root, 'src/praisonai-code/pyproject.toml'), 'utf8'
|
||||
);
|
||||
const codeMatch = codeToml.match(/^version\s*=\s*"([^"]+)"/m);
|
||||
if (!codeMatch) throw new Error('Could not read praisonai-code version');
|
||||
|
||||
const wrapperPy = fs.readFileSync(
|
||||
path.join(root, 'src/praisonai/praisonai/version.py'), 'utf8'
|
||||
);
|
||||
@@ -30,8 +37,10 @@ function readVersionsFromTree(root = '.') {
|
||||
if (!wrapperMatch) throw new Error('Could not read wrapper version');
|
||||
return {
|
||||
currentAgents: agentsMatch[1],
|
||||
currentCode: codeMatch[1],
|
||||
currentWrapper: wrapperMatch[1],
|
||||
targetAgents: bumpPatch(agentsMatch[1]),
|
||||
targetCode: bumpPatch(codeMatch[1]),
|
||||
targetWrapper: bumpPatch(wrapperMatch[1]),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +102,7 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
headSha: headSha || '',
|
||||
lastTag: '',
|
||||
targetAgents: '',
|
||||
targetCode: '',
|
||||
targetWrapper: '',
|
||||
};
|
||||
|
||||
@@ -114,12 +124,17 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
return out;
|
||||
}
|
||||
out.targetAgents = versions.targetAgents;
|
||||
out.targetCode = versions.targetCode;
|
||||
out.targetWrapper = versions.targetWrapper;
|
||||
|
||||
const agentsOnPypi = await pypiVersionExists('praisonaiagents', versions.targetAgents);
|
||||
const codeOnPypi = await pypiVersionExists('praisonai-code', versions.targetCode);
|
||||
const wrapperOnPypi = await pypiVersionExists('praisonai', versions.targetWrapper);
|
||||
if (agentsOnPypi && wrapperOnPypi) {
|
||||
reasons.push(`already published: praisonaiagents==${versions.targetAgents}, praisonai==${versions.targetWrapper}`);
|
||||
if (agentsOnPypi && codeOnPypi && wrapperOnPypi) {
|
||||
reasons.push(
|
||||
`already published: praisonaiagents==${versions.targetAgents}, `
|
||||
+ `praisonai-code==${versions.targetCode}, praisonai==${versions.targetWrapper}`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -184,4 +199,5 @@ module.exports = {
|
||||
pypiVersionExists,
|
||||
evaluateReleasePreflight,
|
||||
ACTIVE_RELEASE_STATUSES,
|
||||
PACKAGE_PATHS,
|
||||
};
|
||||
@@ -1,11 +1,8 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
# Review chain: CodeRabbit + Greptile (+ optional Qodo/Gemini) → Claude (FINAL)
|
||||
# Copilot disabled (REVIEW_CHAIN_SKIP_COPILOT=1) — re-enable copilot-* jobs to restore.
|
||||
# Fork PR jobs use pull_request_target so GH_TOKEN is available for comments.
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
@@ -16,518 +13,108 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REVIEW_CHAIN_SKIP_COPILOT: '1'
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# Claude FINAL after CodeRabbit / Greptile / optional Qodo / Gemini
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
claude-after-prior-reviewers:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summarize by coderabbit')
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' ||
|
||||
github.event.comment.user.login == 'greptile-apps[bot]' ||
|
||||
contains(github.event.comment.user.login, 'gemini-code-assist') ||
|
||||
contains(github.event.comment.user.login, 'qodo-code-review')
|
||||
)) ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.user.login, 'qodo-code-review'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Post Claude FINAL after prior reviewers
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const prNumber =
|
||||
context.payload.issue?.number ||
|
||||
context.payload.pull_request?.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber,
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, prNumber,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core,
|
||||
{ skipCopilot: true, optionalWaitMs: 0, prCreatedAt: pr.created_at }
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
if (result.posted) core.info(`Posted Claude FINAL on PR #${prNumber}`);
|
||||
await ps.ensurePipelineLabels(github, owner, repo, core);
|
||||
await ps.syncPipelineLabels(github, owner, repo, prNumber, core);
|
||||
|
||||
# ================================================================
|
||||
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
|
||||
# Ensures Claude always runs after sufficient review time
|
||||
# FALLBACK: Claude FINAL after PR open if reviewers are slow
|
||||
# ================================================================
|
||||
claude-fallback-timeout:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Wait for review window (15 min)
|
||||
run: sleep 900
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Wait for prior reviewers (25 min)
|
||||
run: sleep 1500
|
||||
|
||||
- name: Post Claude FINAL (skip Copilot)
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
// Collect all bot reviewer comments for context
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
c.user.login.toLowerCase().includes(bot.toLowerCase())
|
||||
)
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await chain.maybeTriggerClaudeFinal(
|
||||
github, context.repo.owner, context.repo.repo, pr,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core,
|
||||
{ skipCopilot: true, optionalWaitMs: 0 }
|
||||
);
|
||||
core.setOutput('review_count', botReviews.length.toString());
|
||||
|
||||
- name: Aggregate reviews and trigger Claude
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
|
||||
comments.data.forEach(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
const body = c.body.substring(0, 500);
|
||||
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
|
||||
else if (login.includes('qodo')) reviews.qodo.push(body);
|
||||
else if (login.includes('gemini')) reviews.gemini.push(body);
|
||||
else if (login.includes('copilot')) reviews.copilot.push(body);
|
||||
else if (login.includes('greptile')) reviews.greptile.push(body);
|
||||
else if (c.user.type === 'Bot') reviews.others.push(body);
|
||||
});
|
||||
const summaryParts = [];
|
||||
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
|
||||
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
|
||||
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const claudeBody = '@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. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\n' +
|
||||
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n' +
|
||||
'Phase 2: FIX valid issues found by prior reviewers.\n' +
|
||||
'Phase 3: Provide final verdict (approve or request changes).';
|
||||
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + context.issue.number + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
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: 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.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
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: 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.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. 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 analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\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.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: post FINAL @claude if merge-conflict comment blocked the chain
|
||||
# (e.g. conflict fired before claude-after-copilot could post review prompt)
|
||||
# Recovery: post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -543,6 +130,7 @@ jobs:
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
@@ -550,6 +138,7 @@ jobs:
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
@@ -559,11 +148,24 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const opts = { skipCopilot: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, opts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -572,18 +174,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReviewerActivity = comments.some(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
return login.includes('coderabbit') || login.includes('copilot') ||
|
||||
login.includes('greptile') || login.includes('qodo') || login.includes('gemini');
|
||||
});
|
||||
if (!hasReviewerActivity) {
|
||||
core.info('No bot reviewer activity yet, skipping recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, opts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
@@ -596,8 +190,8 @@ jobs:
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
|
||||
stale-final-recovery-scheduled:
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs (incl. forks)
|
||||
post-missing-claude-final:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -606,13 +200,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post stale FINAL @claude on open internal PRs
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
@@ -621,28 +216,43 @@ jobs:
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
if (!mergeGate.needsStaleFinalRecovery(comments, headPushedAt)) continue;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr.number, owner, repo,
|
||||
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
|
||||
});
|
||||
posted++;
|
||||
core.info(`Posted scheduled stale-FINAL recovery on PR #${pr.number}`);
|
||||
if (posted >= 5) break;
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core,
|
||||
{ skipCopilot: true, optionalWaitMs: 0 }
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Scheduled stale-FINAL recovery complete (${posted} posted)`);
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# BOT PRs: kick CodeRabbit + Qodo (Greptile may follow on its own)
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
@@ -681,4 +291,4 @@ jobs:
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Auto CI Failure → Claude
|
||||
|
||||
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Core Tests, Optimized Test Suite]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number to process (default: scan blocked PRs)'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
- cron: '15 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect CI failures and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
|
||||
async function processPR(prNumber) {
|
||||
return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const wr = context.payload.workflow_run;
|
||||
const result = await ciFix.processWorkflowRunFailure(
|
||||
github, owner, repo, wr, core
|
||||
);
|
||||
results.push({ event: 'workflow_run', workflow: wr.name, ...result });
|
||||
} else if (context.eventName === 'pull_request') {
|
||||
results.push({
|
||||
pr: context.payload.pull_request.number,
|
||||
...(await processPR(context.payload.pull_request.number)),
|
||||
});
|
||||
} else {
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
|
||||
prNumbers = [Number(context.payload.inputs.pr_number)];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const headRepo = pr.head?.repo?.full_name;
|
||||
if (headRepo && headRepo !== baseRepo) continue;
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
|
||||
prNumbers.push(pr.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
@@ -38,7 +38,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 1
|
||||
MAX_CANDIDATES: 3
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
@@ -56,15 +56,22 @@ jobs:
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PAT: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -72,11 +79,6 @@ jobs:
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (!process.env.PAT) {
|
||||
core.setFailed('GH_TOKEN secret is required for merge gate auto-dispatch (needs repo + workflow scope).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
@@ -121,17 +123,26 @@ jobs:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -156,10 +167,9 @@ jobs:
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100, sort: 'created', direction: 'asc',
|
||||
});
|
||||
const prNumbers = prs.map((p) => p.number);
|
||||
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
|
||||
github, owner, repo, core
|
||||
);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
@@ -196,7 +206,7 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
@@ -230,7 +240,7 @@ jobs:
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
@@ -252,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 }}
|
||||
@@ -299,11 +308,12 @@ jobs:
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback verdict if Claude did not comment
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
@@ -312,24 +322,24 @@ jobs:
|
||||
|
||||
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);
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.setOutput('verdict', 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 — Claude assess did not post a verdict comment (e.g. GitHub MCP unavailable).',
|
||||
'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}`));
|
||||
}
|
||||
@@ -337,14 +347,12 @@ jobs:
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Verify verdict posted
|
||||
id: verdict
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
@@ -354,15 +362,23 @@ jobs:
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, minCreatedAt, ctx.headPushedAt);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) core.warning('No recent MERGE_GATE_VERDICT comment found after assessment');
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
@@ -396,16 +412,25 @@ jobs:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -428,7 +453,9 @@ jobs:
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, verdictMin, ctx.headPushedAt);
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
|
||||
@@ -133,6 +133,9 @@ jobs:
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]'
|
||||
concurrency:
|
||||
group: claude-response-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -7,12 +7,18 @@ on:
|
||||
- 'src/praisonai/praisonai/integration/**'
|
||||
- 'src/praisonai/tests/integration/test_aiui_*'
|
||||
- 'src/praisonai/pyproject.toml'
|
||||
- 'src/praisonai-code/**'
|
||||
- '.github/workflows/integration-cross-repo.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'src/praisonai/praisonai/integration/**'
|
||||
- 'src/praisonai/tests/integration/test_aiui_*'
|
||||
- 'src/praisonai/pyproject.toml'
|
||||
- 'src/praisonai-code/**'
|
||||
- '.github/workflows/integration-cross-repo.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -40,15 +46,14 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip
|
||||
- uses: PraisonAI/.github/actions/install-monorepo-packages
|
||||
with:
|
||||
repo-root: PraisonAI/
|
||||
wrapper-extras: ".[ui,dev]"
|
||||
- name: Install PraisonAIUI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install PraisonAI packages in development mode
|
||||
cd PraisonAI/src/praisonai-agents
|
||||
pip install -e .
|
||||
cd ../praisonai
|
||||
pip install -e ".[ui,dev]"
|
||||
# Install PraisonAIUI in development mode (pytest-cov required by pyproject addopts)
|
||||
cd ../../../PraisonAIUI
|
||||
cd PraisonAIUI
|
||||
pip install -e .
|
||||
pip install pytest pytest-cov
|
||||
|
||||
@@ -138,13 +143,14 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip
|
||||
- uses: PraisonAI/.github/actions/install-monorepo-packages
|
||||
with:
|
||||
repo-root: PraisonAI/
|
||||
wrapper-extras: ".[ui,dev]"
|
||||
- name: Install PraisonAIUI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd PraisonAI/src/praisonai-agents
|
||||
pip install -e .
|
||||
cd ../praisonai
|
||||
pip install -e ".[ui,dev]"
|
||||
cd ../../../PraisonAIUI
|
||||
cd PraisonAIUI
|
||||
pip install -e .
|
||||
|
||||
- name: Run agentic integration test
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
|
||||
@@ -94,13 +94,17 @@ jobs:
|
||||
core.setOutput('head_sha', result.headSha || headSha);
|
||||
core.setOutput('last_tag', result.lastTag || '');
|
||||
core.setOutput('target_agents', result.targetAgents || '');
|
||||
core.setOutput('target_code', result.targetCode || '');
|
||||
core.setOutput('target_wrapper', result.targetWrapper || '');
|
||||
|
||||
core.summary.addRaw('## Release gate preflight\n');
|
||||
core.summary.addRaw(`Ready: ${result.ready}\n`);
|
||||
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
|
||||
if (result.targetAgents) {
|
||||
core.summary.addRaw(`Targets: praisonaiagents==${result.targetAgents}, praisonai==${result.targetWrapper}\n`);
|
||||
core.summary.addRaw(
|
||||
`Targets: praisonaiagents==${result.targetAgents}, `
|
||||
+ `praisonai-code==${result.targetCode}, praisonai==${result.targetWrapper}\n`
|
||||
);
|
||||
}
|
||||
await core.summary.write();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
@@ -20,10 +21,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
|
||||
@@ -49,10 +49,12 @@ jobs:
|
||||
git config --local user.name "PraisonAI Bot"
|
||||
|
||||
- name: Install PraisonAI
|
||||
run: |
|
||||
pip install uv
|
||||
cd src/praisonai-agents && uv pip install --system -e ".[all]"
|
||||
cd ../praisonai && uv pip install --system -e ".[all]"
|
||||
run: pip install uv
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[all]"
|
||||
|
||||
- name: Run PraisonAI Issue Triage
|
||||
env:
|
||||
|
||||
@@ -16,16 +16,20 @@ jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@praisonai') &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
secrets.PRAISONAI_APP_ID != '' &&
|
||||
secrets.PRAISONAI_APP_PRIVATE_KEY != '' &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@praisonai') &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Version bump type for both packages'
|
||||
description: 'Version bump type for all packages (agents, code, wrapper)'
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
@@ -27,7 +27,17 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
skip_wrapper:
|
||||
description: 'Publish agents only'
|
||||
description: 'Skip wrapper publish (agents and/or code only)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_code:
|
||||
description: 'Skip praisonai-code publish (recovery if already on PyPI)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_bot:
|
||||
description: 'Skip praisonai-bot publish (recovery if already on PyPI)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -41,6 +51,16 @@ on:
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
code_version:
|
||||
description: 'Override praisonai-code version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
bot_version:
|
||||
description: 'Override praisonai-bot version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
source:
|
||||
description: 'Trigger source (audit trail)'
|
||||
required: false
|
||||
@@ -112,7 +132,11 @@ jobs:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
AGENTS_OVERRIDE: ${{ inputs.agents_version }}
|
||||
WRAPPER_OVERRIDE: ${{ inputs.wrapper_version }}
|
||||
CODE_OVERRIDE: ${{ inputs.code_version }}
|
||||
BOT_OVERRIDE: ${{ inputs.bot_version }}
|
||||
SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
SKIP_CODE: ${{ inputs.skip_code }}
|
||||
SKIP_BOT: ${{ inputs.skip_bot }}
|
||||
run: |
|
||||
python <<'PY'
|
||||
import os
|
||||
@@ -143,8 +167,12 @@ jobs:
|
||||
|
||||
agents_override = os.environ.get("AGENTS_OVERRIDE", "").strip()
|
||||
wrapper_override = os.environ.get("WRAPPER_OVERRIDE", "").strip()
|
||||
code_override = os.environ.get("CODE_OVERRIDE", "").strip()
|
||||
bot_override = os.environ.get("BOT_OVERRIDE", "").strip()
|
||||
validate_version("agents_version", agents_override)
|
||||
validate_version("wrapper_version", wrapper_override)
|
||||
validate_version("code_version", code_override)
|
||||
validate_version("bot_version", bot_override)
|
||||
|
||||
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
|
||||
agents_content = agents_pyproject.read_text()
|
||||
@@ -153,9 +181,22 @@ jobs:
|
||||
print("Could not read agents version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_agents = agents_match.group(1)
|
||||
if not SEMVER.match(current_agents):
|
||||
print(f"Invalid current agents version: {current_agents}", file=sys.stderr)
|
||||
|
||||
code_pyproject = root / "src/praisonai-code/pyproject.toml"
|
||||
code_content = code_pyproject.read_text()
|
||||
code_match = re.search(r'^version\s*=\s*"([^"]+)"', code_content, re.MULTILINE)
|
||||
if not code_match:
|
||||
print("Could not read praisonai-code version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_code = code_match.group(1)
|
||||
|
||||
bot_pyproject = root / "src/praisonai-bot/pyproject.toml"
|
||||
bot_content = bot_pyproject.read_text()
|
||||
bot_match = re.search(r'^version\s*=\s*"([^"]+)"', bot_content, re.MULTILINE)
|
||||
if not bot_match:
|
||||
print("Could not read praisonai-bot version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_bot = bot_match.group(1)
|
||||
|
||||
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
|
||||
wrapper_content = wrapper_version_py.read_text()
|
||||
@@ -164,11 +205,20 @@ jobs:
|
||||
print("Could not read wrapper version from version.py", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_wrapper = wrapper_match.group(1)
|
||||
if not SEMVER.match(current_wrapper):
|
||||
print(f"Invalid current wrapper version: {current_wrapper}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for label, value in (
|
||||
("agents", current_agents),
|
||||
("code", current_code),
|
||||
("bot", current_bot),
|
||||
("wrapper", current_wrapper),
|
||||
):
|
||||
if not SEMVER.match(value):
|
||||
print(f"Invalid current {label} version: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
skip_agents = os.environ.get("SKIP_AGENTS", "false").lower() == "true"
|
||||
skip_code = os.environ.get("SKIP_CODE", "false").lower() == "true"
|
||||
skip_bot = os.environ.get("SKIP_BOT", "false").lower() == "true"
|
||||
|
||||
if agents_override:
|
||||
agents_version = agents_override
|
||||
@@ -177,6 +227,20 @@ jobs:
|
||||
else:
|
||||
agents_version = bump_version(current_agents)
|
||||
|
||||
if code_override:
|
||||
code_version = code_override
|
||||
elif skip_code:
|
||||
code_version = current_code
|
||||
else:
|
||||
code_version = bump_version(current_code)
|
||||
|
||||
if bot_override:
|
||||
bot_version = bot_override
|
||||
elif skip_bot:
|
||||
bot_version = current_bot
|
||||
else:
|
||||
bot_version = bump_version(current_bot)
|
||||
|
||||
if wrapper_override:
|
||||
wrapper_version = wrapper_override
|
||||
else:
|
||||
@@ -185,11 +249,17 @@ jobs:
|
||||
github_output = os.environ["GITHUB_OUTPUT"]
|
||||
with open(github_output, "a", encoding="utf-8") as fh:
|
||||
fh.write(f"agents_version={agents_version}\n")
|
||||
fh.write(f"code_version={code_version}\n")
|
||||
fh.write(f"bot_version={bot_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")
|
||||
fh.write(f"current_bot={current_bot}\n")
|
||||
fh.write(f"current_wrapper={current_wrapper}\n")
|
||||
|
||||
print(f"Agents: {current_agents} -> {agents_version}")
|
||||
print(f"Code: {current_code} -> {code_version}")
|
||||
print(f"Bot: {current_bot} -> {bot_version}")
|
||||
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
|
||||
PY
|
||||
|
||||
@@ -198,29 +268,45 @@ jobs:
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
AGENTS_VERSION: ${{ steps.versions.outputs.agents_version }}
|
||||
CODE_VERSION: ${{ steps.versions.outputs.code_version }}
|
||||
BOT_VERSION: ${{ steps.versions.outputs.bot_version }}
|
||||
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
|
||||
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
|
||||
INPUT_SKIP_BOT: ${{ inputs.skip_bot }}
|
||||
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skip_agents="${INPUT_SKIP_AGENTS}"
|
||||
skip_code="${INPUT_SKIP_CODE}"
|
||||
skip_bot="${INPUT_SKIP_BOT}"
|
||||
skip_wrapper="${INPUT_SKIP_WRAPPER}"
|
||||
if [ "$skip_agents" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
|
||||
fi
|
||||
if [ "$skip_code" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
|
||||
fi
|
||||
if [ "$skip_bot" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-bot/${BOT_VERSION}/json" >/dev/null && skip_bot=true || true
|
||||
fi
|
||||
if [ "$skip_wrapper" != "true" ]; then
|
||||
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"
|
||||
echo "skip_bot=${skip_bot}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$skip_agents" = "true" ] && [ "$skip_wrapper" = "true" ]; then
|
||||
echo "Both target versions already on PyPI — nothing to publish."
|
||||
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_wrapper" = "true" ]; then
|
||||
echo "All target versions already on PyPI — nothing to publish."
|
||||
fi
|
||||
|
||||
- name: Exit if already published
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
steps.pypi_exists.outputs.skip_agents == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_code == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_bot == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_wrapper == 'true'
|
||||
run: echo "Idempotent skip — target versions already on PyPI."
|
||||
|
||||
@@ -229,14 +315,21 @@ jobs:
|
||||
run: |
|
||||
echo "Dry run — no publish, commit, or push."
|
||||
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 "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
|
||||
echo ""
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
|
||||
echo "Would commit src/praisonai-agents/pyproject.toml and uv.lock"
|
||||
fi
|
||||
if [ "${{ inputs.skip_code }}" != "true" ]; then
|
||||
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_bot }}" != "true" ]; then
|
||||
echo "Would publish praisonai-bot ${{ steps.versions.outputs.bot_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
|
||||
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }}"
|
||||
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 }}"
|
||||
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
|
||||
fi
|
||||
|
||||
@@ -306,6 +399,146 @@ jobs:
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-code
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_code != true &&
|
||||
steps.pypi_exists.outputs.skip_code != 'true'
|
||||
working-directory: src/praisonai-code
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.versions.outputs.code_version }}
|
||||
CURRENT: ${{ steps.versions.outputs.current_code }}
|
||||
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))
|
||||
init_py = Path("praisonai_code/__init__.py")
|
||||
init_content = init_py.read_text()
|
||||
init_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', init_content, count=1))
|
||||
PY
|
||||
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
|
||||
- name: Wait for praisonai-code on PyPI
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_code != true &&
|
||||
steps.pypi_exists.outputs.skip_code != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="praisonai-code"
|
||||
VERSION="${{ steps.versions.outputs.code_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
|
||||
|
||||
- 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 &&
|
||||
inputs.skip_bot != true &&
|
||||
steps.pypi_exists.outputs.skip_bot != 'true'
|
||||
working-directory: src/praisonai-bot
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.versions.outputs.bot_version }}
|
||||
CURRENT: ${{ steps.versions.outputs.current_bot }}
|
||||
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_bot/_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
|
||||
uv publish
|
||||
|
||||
- name: Wait for praisonai-bot on PyPI
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_bot != true &&
|
||||
steps.pypi_exists.outputs.skip_bot != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="praisonai-bot"
|
||||
VERSION="${{ steps.versions.outputs.bot_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
|
||||
|
||||
- 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: Bump and release wrapper
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -315,6 +548,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
python src/praisonai/scripts/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 }}" \
|
||||
--wait --max-wait 600 --no-add-all
|
||||
|
||||
- name: Publish praisonai wrapper to PyPI
|
||||
@@ -357,12 +592,22 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AGENTS="${{ steps.versions.outputs.agents_version }}"
|
||||
CODE="${{ steps.versions.outputs.code_version }}"
|
||||
BOT="${{ steps.versions.outputs.bot_version }}"
|
||||
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
|
||||
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
echo "✅ praisonaiagents==${AGENTS}"
|
||||
fi
|
||||
|
||||
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_wrapper }}" != "true" ]; then
|
||||
echo "✅ praisonai==${WRAPPER}"
|
||||
fi
|
||||
@@ -30,10 +30,12 @@ jobs:
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
- name: Install test tools
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
python -m pip install duckduckgo_search pytest pytest-asyncio pytest-cov
|
||||
python -m pip install "praisonaiagents[knowledge]"
|
||||
|
||||
|
||||
@@ -53,12 +53,13 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-benchmark pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
|
||||
- name: Verify environment variables
|
||||
run: |
|
||||
|
||||
@@ -7,12 +7,14 @@ on:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -44,10 +46,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-timeout pytest-xdist
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
@@ -79,10 +84,15 @@ jobs:
|
||||
tests/unit/tui/
|
||||
extra_ignore: --ignore=tests/unit/cli/test_message_queue.py
|
||||
- shard: bots-gateway
|
||||
workdir: src/praisonai-bot
|
||||
paths: >-
|
||||
tests/unit/bots/
|
||||
tests/unit/gateway/
|
||||
tests/unit/cli/
|
||||
tests/unit/daemon/
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
- shard: subdirs
|
||||
paths: >-
|
||||
tests/unit/scheduler/
|
||||
@@ -108,7 +118,6 @@ jobs:
|
||||
tests/unit/orchestration/
|
||||
tests/unit/dev/
|
||||
tests/unit/_dev/
|
||||
tests/unit/daemon/
|
||||
tests/unit/context/
|
||||
tests/unit/adapters/
|
||||
tests/unit/templates/
|
||||
@@ -134,10 +143,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-timeout pytest-xdist
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
@@ -148,7 +160,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
WORKDIR="${{ matrix.workdir || 'src/praisonai' }}"
|
||||
cd "$WORKDIR"
|
||||
if [ -n "${{ matrix.pythonpath || '' }}" ]; then
|
||||
export PYTHONPATH="${{ matrix.pythonpath }}:${PYTHONPATH:-}"
|
||||
fi
|
||||
PYTEST_ARGS=(
|
||||
-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"
|
||||
-q --tb=line --disable-warnings
|
||||
@@ -199,11 +215,13 @@ jobs:
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-timeout
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
|
||||
@@ -29,11 +29,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
@@ -95,11 +96,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system pytest pytest-benchmark
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
|
||||
@@ -49,10 +49,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# ============================================
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -64,12 +64,11 @@ jobs:
|
||||
- name: Install minimal dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
# Install praisonai-agents with all deps (required for tests)
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[chat,code]"
|
||||
pip install pytest pytest-asyncio pytest-timeout prompt_toolkit
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[chat,code]"
|
||||
|
||||
- name: Run Smoke Tests
|
||||
env:
|
||||
@@ -80,13 +79,119 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
python -m pytest tests/unit/test_c5_backward_compat.py \
|
||||
tests/unit/test_c7_1_boundaries.py \
|
||||
tests/unit/test_c9_backward_compat.py \
|
||||
tests/unit/test_c9_1_boundaries.py \
|
||||
-q --tb=line --timeout=30 --maxfail=5
|
||||
python -m pytest tests/unit/cli/ \
|
||||
../praisonai-bot/tests/unit/cli/test_onboard_command.py \
|
||||
../praisonai-bot/tests/unit/cli/test_onboard_non_interactive.py \
|
||||
-m "not slow and not network" \
|
||||
-q --tb=line \
|
||||
--timeout=30 \
|
||||
--ignore=tests/fixtures \
|
||||
--ignore=tests/unit/cli/test_message_queue.py \
|
||||
--maxfail=5
|
||||
|
||||
- name: Standalone praisonai-code import smoke
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m venv /tmp/praisonai-code-standalone
|
||||
/tmp/praisonai-code-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-code
|
||||
/tmp/praisonai-code-standalone/bin/python -c "
|
||||
import praisonai_code
|
||||
from praisonai_code.cli.configuration.resolver import ConfigResolver
|
||||
from praisonai_code._version import get_package_version
|
||||
from praisonai_code.llm.credentials import is_configured
|
||||
from praisonai_code.llm.catalogue import ModelCatalogue
|
||||
import toml
|
||||
assert hasattr(ConfigResolver, '__name__')
|
||||
assert callable(is_configured)
|
||||
assert ModelCatalogue().list_models(provider='openai')
|
||||
import praisonai_code.cli.main
|
||||
import praisonai_code.cli.app
|
||||
from praisonai_code.tool_resolver import ToolResolver
|
||||
assert ToolResolver is not None
|
||||
print('standalone ok', get_package_version(), 'toml', toml.__version__)
|
||||
"
|
||||
/tmp/praisonai-code-standalone/bin/praisonai-code --version
|
||||
/tmp/praisonai-code-standalone/bin/praisonai-code run --help
|
||||
cd src/praisonai-code
|
||||
for f in praisonai_code/cli/main.py praisonai_code/cli/app.py \
|
||||
praisonai_code/cli/commands/run.py praisonai_code/cli/commands/chat.py \
|
||||
praisonai_code/cli/commands/code.py; do
|
||||
if grep -E '^from praisonai\.|^import praisonai' "$f"; then
|
||||
echo "FAIL: module-level wrapper import in $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "C7 hot-path import gate ok"
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c7_imports.sh"
|
||||
# Standalone actions run (no wrapper); default run/chat require praisonai
|
||||
set +e
|
||||
RUN_OUT=$(timeout 90 /tmp/praisonai-code-standalone/bin/praisonai-code run --output actions "Say hello in one word" 2>&1)
|
||||
RUN_CODE=$?
|
||||
set -e
|
||||
echo "$RUN_OUT" | tail -20
|
||||
if echo "$RUN_OUT" | grep -qE 'ImportError|ModuleNotFoundError: No module named .praisonai'; then
|
||||
echo "FAIL: standalone actions run hit wrapper import error"
|
||||
exit 1
|
||||
fi
|
||||
echo "standalone actions run import gate ok (exit $RUN_CODE)"
|
||||
set +e
|
||||
DEFAULT_RUN=$(timeout 15 /tmp/praisonai-code-standalone/bin/praisonai-code run "hi" 2>&1)
|
||||
set -e
|
||||
echo "$DEFAULT_RUN" | tail -10
|
||||
if ! echo "$DEFAULT_RUN" | grep -qi 'install praisonai'; then
|
||||
echo "FAIL: default run should prompt for praisonai wrapper install"
|
||||
exit 1
|
||||
fi
|
||||
echo "default run wrapper gate ok"
|
||||
set +e
|
||||
CHAT_RUN=$(timeout 10 /tmp/praisonai-code-standalone/bin/praisonai-code chat 2>&1)
|
||||
set -e
|
||||
echo "$CHAT_RUN" | tail -10
|
||||
if ! echo "$CHAT_RUN" | grep -qi 'install praisonai'; then
|
||||
echo "FAIL: chat should prompt for praisonai wrapper install"
|
||||
exit 1
|
||||
fi
|
||||
echo "chat wrapper gate ok"
|
||||
|
||||
- name: Standalone praisonai-bot smoke (C9)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m venv /tmp/praisonai-bot-standalone
|
||||
/tmp/praisonai-bot-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-bot
|
||||
/tmp/praisonai-bot-standalone/bin/python -c "
|
||||
from praisonai_bot._version import __version__ as pkg_version
|
||||
import praisonai_bot
|
||||
from praisonai_bot.bots import Bot
|
||||
from praisonai_bot.bots._registry import resolve_adapter
|
||||
from praisonai_bot.scheduler.executor import ScheduledAgentExecutor
|
||||
from praisonai_bot.gateway import WebSocketGateway
|
||||
assert praisonai_bot.__version__ == pkg_version
|
||||
telegram = resolve_adapter('telegram')
|
||||
assert telegram.__name__ == 'TelegramBot'
|
||||
assert telegram.__module__.startswith('praisonai_bot.bots.')
|
||||
assert ScheduledAgentExecutor.__module__.startswith('praisonai_bot.')
|
||||
print('bot standalone ok', praisonai_bot.__version__, telegram.__name__)
|
||||
"
|
||||
/tmp/praisonai-bot-standalone/bin/praisonai-bot --help
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c9_bot_imports.sh"
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_bot_code_imports.sh"
|
||||
/tmp/praisonai-bot-standalone/bin/python -c "
|
||||
import praisonai_bot.cli.commands.bot as bot_cmd
|
||||
import praisonai_bot.cli.commands.gateway as gw_cmd
|
||||
print('bot command modules import ok', bot_cmd.app, gw_cmd.app)
|
||||
"
|
||||
|
||||
- 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
|
||||
python scripts/audit_hybrid_modules.py
|
||||
|
||||
- name: Smoke Test Summary
|
||||
if: always()
|
||||
@@ -125,12 +230,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
# Install praisonai-agents with all deps (required for tests)
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[crewai,autogen,ui,chat]"
|
||||
pip install pytest pytest-asyncio pytest-timeout pytest-cov pytest-xdist
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[crewai,autogen,ui,chat]"
|
||||
|
||||
- name: Run Main Tests
|
||||
env:
|
||||
@@ -214,11 +318,12 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[openai]"
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[openai]"
|
||||
|
||||
- name: Run OpenAI Live Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -277,11 +382,12 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[anthropic]"
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[anthropic]"
|
||||
|
||||
- name: Run Anthropic Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -336,11 +442,12 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[google]"
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[google]"
|
||||
|
||||
- name: Run Google/Gemini Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -395,11 +502,11 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
|
||||
- name: Run Groq Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -454,11 +561,11 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
|
||||
- name: Run xAI/Grok Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -513,11 +620,12 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e ".[all]"
|
||||
cd ../praisonai
|
||||
pip install -e ".[cohere]"
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[cohere]"
|
||||
|
||||
- name: Run Cohere Tests
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
@@ -539,6 +647,76 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'false'
|
||||
run: echo "✅ Cohere tests skipped (no API key available)"
|
||||
|
||||
# ============================================
|
||||
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
|
||||
# Nightly / extended tier only (~401 tests)
|
||||
# ============================================
|
||||
legacy-unit:
|
||||
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
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: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
wrapper-extras: ".[chat,code,crewai,autogen]"
|
||||
|
||||
- name: Run post-C6 legacy unit tests
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '0'
|
||||
PRAISONAI_ALLOW_JOB_WORKFLOWS: 'true'
|
||||
PRAISONAI_TOOL_SAFETY: 'off'
|
||||
OPENAI_API_KEY: 'test-key'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
LEGACY_FILES=(
|
||||
tests/unit/test_warm_runtime.py
|
||||
tests/unit/test_yaml_cli_backend.py
|
||||
tests/unit/test_tool_resolver.py
|
||||
tests/unit/test_serve_unified.py
|
||||
tests/unit/test_security_fixes_core.py
|
||||
tests/unit/test_query_rewriter_cli.py
|
||||
tests/unit/test_kanban_sqlite_store.py
|
||||
tests/unit/test_job_workflow_agent_steps.py
|
||||
tests/unit/test_hybrid_workflow.py
|
||||
../praisonai-bot/tests/unit/test_gateway_session.py
|
||||
../praisonai-bot/tests/unit/test_gateway_gaps.py
|
||||
tests/unit/test_discord_approval.py
|
||||
tests/unit/test_decorator_simple.py
|
||||
tests/unit/test_db_adapter_tracing_init.py
|
||||
../praisonai-bot/tests/unit/test_bot_wiring.py
|
||||
../praisonai-bot/tests/unit/test_bot_history.py
|
||||
../praisonai-bot/tests/unit/test_bot_fixes.py
|
||||
tests/unit/test_auto_lazy_loading.py
|
||||
tests/unit/test_async_daemon_deployment.py
|
||||
tests/unit/test_approval_interactive.py
|
||||
tests/unit/test_approval_agent_integration.py
|
||||
tests/unit/test_agents_yaml_validation.py
|
||||
tests/unit/test_agent_os_wrapper.py
|
||||
tests/unit/test_lazy_imports.py
|
||||
tests/unit/test_profiler.py
|
||||
tests/unit/test_profiler_advanced.py
|
||||
tests/unit/test_serverless_postgres.py
|
||||
)
|
||||
python -m pytest "${LEGACY_FILES[@]}" \
|
||||
-q --tb=line --timeout=120 --maxfail=10
|
||||
|
||||
# ============================================
|
||||
# TEST SUMMARY
|
||||
# ============================================
|
||||
|
||||
@@ -61,11 +61,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
use-uv: "true"
|
||||
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
|
||||
@@ -7,12 +7,14 @@ on:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -33,18 +35,10 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install praisonaiagents
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install -e .
|
||||
shell: bash
|
||||
|
||||
- name: Install praisonai wrapper
|
||||
run: |
|
||||
cd src/praisonai
|
||||
pip install -e .
|
||||
- name: Install monorepo packages
|
||||
run: python -m pip install --upgrade pip
|
||||
shell: bash
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
|
||||
# ==================== CORE IMPORTS ====================
|
||||
- name: Test core imports
|
||||
@@ -182,6 +176,7 @@ jobs:
|
||||
echo "Testing both entry point methods..."
|
||||
python -m praisonai --version
|
||||
praisonai --version
|
||||
praisonai-bot gateway --help
|
||||
|
||||
- name: Test doctor packaging command
|
||||
run: |
|
||||
|
||||
@@ -22,6 +22,8 @@ other
|
||||
output
|
||||
!src/praisonai/praisonai/cli/output/
|
||||
!src/praisonai-agents/praisonaiagents/output/
|
||||
!src/praisonai-code/praisonai_code/cli/output/
|
||||
!src/praisonai-bot/praisonai_bot/cli/output/
|
||||
|
||||
.files
|
||||
threads.db
|
||||
|
||||
@@ -7,4 +7,5 @@ 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: core protocols in praisonaiagents/, heavy implementations in praisonai/
|
||||
- Follow protocol-driven design: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bot/channel and heavy implementations in `praisonai/`
|
||||
- Preserve old `praisonai.cli.*` import paths via shims when changing moved CLI code (see §2.3 in `src/praisonai-agents/AGENTS.md`)
|
||||
+149
-35
@@ -1,24 +1,27 @@
|
||||
# PraisonAI Architecture
|
||||
|
||||
> **Last updated:** 2026-06-24
|
||||
> **Last updated:** 2026-07-03 (C9 — four-tier package model)
|
||||
>
|
||||
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
|
||||
> Covers system design, runtime architecture, data contracts, reliability,
|
||||
> observability, and the 2-quarter road map.
|
||||
> Covers **Python four-tier package model (C7.1 + C9)**, system design, runtime
|
||||
> architecture, data contracts, reliability, observability, and the road map.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [System Overview](#2-system-overview)
|
||||
3. [Layered Architecture](#3-layered-architecture)
|
||||
4. [Core Data Contracts](#4-core-data-contracts)
|
||||
5. [Reliability by Design](#5-reliability-by-design)
|
||||
6. [Observability & Telemetry](#6-observability--telemetry)
|
||||
7. [Replay & Checkpointing](#7-replay--checkpointing)
|
||||
8. [Implementation Roadmap](#8-implementation-roadmap)
|
||||
9. [Success Metrics](#9-success-metrics)
|
||||
2. [Python Four-Tier Package Model (C7.1 + C9)](#2-python-four-tier-package-model-c71--c9)
|
||||
3. [System Overview](#3-system-overview)
|
||||
4. [Layered Architecture](#4-layered-architecture)
|
||||
5. [Core Data Contracts](#5-core-data-contracts)
|
||||
6. [Reliability by Design](#6-reliability-by-design)
|
||||
7. [Observability & Telemetry](#7-observability--telemetry)
|
||||
8. [Replay & Checkpointing](#8-replay--checkpointing)
|
||||
9. [Implementation Roadmap](#9-implementation-roadmap)
|
||||
10. [Success Metrics](#10-success-metrics)
|
||||
|
||||
**Related boundary docs:** [`src/praisonai/tests/C7.1_BOUNDARIES.md`](src/praisonai/tests/C7.1_BOUNDARIES.md) · [`src/praisonai/tests/C9.1_BOUNDARIES.md`](src/praisonai/tests/C9.1_BOUNDARIES.md) · [`src/praisonai/tests/C7_VERIFICATION.md`](src/praisonai/tests/C7_VERIFICATION.md) · [`src/praisonai/tests/C9_VERIFICATION.md`](src/praisonai/tests/C9_VERIFICATION.md) · [`src/praisonai-agents/AGENTS.md`](src/praisonai-agents/AGENTS.md) §2.4
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +51,101 @@ Orchestration + Observability** core to unlock adoption and trust:
|
||||
|
||||
---
|
||||
|
||||
## 2. System Overview
|
||||
## 2. Python Four-Tier Package Model (C7.1 + C9)
|
||||
|
||||
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai`
|
||||
|
||||
The Python monorepo publishes four 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`.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph tier1 [Tier 1 — Core SDK]
|
||||
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
|
||||
end
|
||||
|
||||
subgraph tier2 [Tier 2 — Terminal + Bot]
|
||||
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM"]
|
||||
Bot["praisonai-bot<br/>bots, gateway, channel CLI, OS daemon"]
|
||||
end
|
||||
|
||||
subgraph tier3 [Tier 3 — Wrapper]
|
||||
Wrapper["praisonai<br/>framework_adapters, train, serve, dashboard"]
|
||||
end
|
||||
|
||||
Agents --> Code
|
||||
Agents --> Bot
|
||||
Agents --> Wrapper
|
||||
Code -.->|"lazy _bot_bridge"| Bot
|
||||
Code -.->|"lazy _wrapper_bridge"| Wrapper
|
||||
Bot -.->|"lazy _code_bridge / _wrapper_bridge"| Code
|
||||
Bot -.->|"lazy _wrapper_bridge"| Wrapper
|
||||
```
|
||||
|
||||
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` → `praisonai`
|
||||
|
||||
**Backward compatibility:** `praisonai.bots`, `praisonai.gateway`, and related CLI
|
||||
paths remain as `alias_package` shims to `praisonai_bot.*`.
|
||||
|
||||
| Tier | Package | Owns | Must not depend on |
|
||||
|------|---------|------|-------------------|
|
||||
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols | `praisonai`, `praisonai-code`, `praisonai-bot` |
|
||||
| 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) |
|
||||
| 3 | `src/praisonai/` | `framework_adapters/`, train, serve, dashboard, async jobs API | — |
|
||||
|
||||
**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`).
|
||||
|
||||
### Dependency rule (validated)
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| Does `praisonai-code` declare `praisonai` in `pyproject.toml`? | **No** — only `praisonaiagents` + CLI/runtime deps |
|
||||
| Does `praisonai` declare `praisonai-code`? | **Yes** — one-way chain, no PyPI cycle |
|
||||
| Can `praisonai-code` import `praisonai.*` at runtime? | **Only lazily** via `praisonai_code._wrapper_bridge` for optional features; **not** on the agentic hot path |
|
||||
| Does standalone `pip install praisonai-code` work? | **Yes** — CI smoke validates imports + `praisonai-code run` without the wrapper |
|
||||
|
||||
### CLI routing
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User] --> Entry{Entry point}
|
||||
Entry -->|pip install praisonai-code| CodeCLI["praisonai-code run/chat/code"]
|
||||
Entry -->|pip install praisonai| WrapperCLI["praisonai …"]
|
||||
WrapperCLI --> Router["praisonai.__main__"]
|
||||
Router --> CodePath["praisonai_code.cli.app"]
|
||||
Router --> Legacy["praisonai.cli.main"]
|
||||
CodePath --> HotPath["run / chat / code"]
|
||||
CodePath --> Bridge{"wrapper_available?"}
|
||||
Bridge -->|yes| WrapperCmds["bot, gateway, pairing, …"]
|
||||
Bridge -->|no| Hidden["Wrapper commands hidden"]
|
||||
```
|
||||
|
||||
### Import gates (CI enforced)
|
||||
|
||||
- **Hot path:** no module-level `from praisonai` in `main.py`, `app.py`, `run.py`, `chat.py`, `code.py`
|
||||
- **Regression baseline:** 50 direct wrapper import lines max (`scripts/check_c7_imports.sh`; C8 achieved **0**)
|
||||
- **Allowlist:** reviewed files only (`scripts/c7_wrapper_import_allowlist.txt`; empty post-C8)
|
||||
- **Hybrid audit:** `scripts/audit_hybrid_modules.py` — repatriated cross-tier import paths
|
||||
|
||||
### C7 / C7.1 / C8 status
|
||||
|
||||
| Milestone | Status |
|
||||
|-----------|--------|
|
||||
| Standalone agentic hot path | **Complete** |
|
||||
| `_wrapper_bridge` hardening | **Complete** |
|
||||
| Import gate + allowlist | **Complete** |
|
||||
| Boundary tests + CI smoke | **Complete** |
|
||||
| C8 reverse import elimination (225 → 0 direct imports) | **Complete** — see [`C8_BACKLOG.md`](src/praisonai/tests/C8_BACKLOG.md) |
|
||||
| C8.4 main.py physical decomposition | **Deferred** (separate epic) |
|
||||
| PyPI package splits (`praisonai-bot`, etc.) | **Out of scope** |
|
||||
|
||||
---
|
||||
|
||||
## 3. System Overview
|
||||
|
||||
```
|
||||
User/SDK/CLI
|
||||
@@ -75,33 +172,42 @@ User/SDK/CLI
|
||||
|
||||
### Existing Architecture (Python SDK)
|
||||
|
||||
The Python SDK is the primary runtime and is organised as follows:
|
||||
The Python SDK is organised as three publishable tiers (see [§2](#2-python-three-tier-package-model-c71)):
|
||||
|
||||
- `src/praisonai/` — Core runtime and agent abstractions
|
||||
- `agents/` — Agent types (chat, code, realtime, audio, vision, etc.)
|
||||
- `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)
|
||||
- `workflow/` — Workflow engine (YAML/SDK-based orchestration)
|
||||
- `telemetry/` — Observability, monitoring, token tracking
|
||||
- `reliability/` — Error classification, circuit breaker, failover
|
||||
- `skill/` — Plugin/skill system
|
||||
- `loop_detection/` — Doom loop detection and prevention
|
||||
- `mcp/` — MCP protocol support
|
||||
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols
|
||||
- `src/praisonai-agents/` — Core SDK (`Agent`, tools, memory, hooks, protocols)
|
||||
- `src/praisonai-code/` — Terminal CLI (`run`, `chat`, `code`, Typer, runtime, LLM)
|
||||
- `src/praisonai/` — Wrapper (gateway, bots, `framework_adapters/`, integrations)
|
||||
|
||||
Core execution modules live in `praisonaiagents`:
|
||||
|
||||
- `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/`:
|
||||
|
||||
- `gateway/`, `bots/` — Multi-bot orchestration (BotOS)
|
||||
- `framework_adapters/` — CrewAI, AutoGen, PraisonAI adapters
|
||||
- `cli/commands/` — Wrapper commands (`bot`, `gateway`, `pairing`, …)
|
||||
|
||||
### Multi-SDK Layout
|
||||
|
||||
- **Python SDK** — Primary runtime (`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 (`ts-sdk/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
|
||||
- **CLI** — Command-line interface (`cli/`)
|
||||
- **UI** — Web UI (`ui/`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Layered Architecture
|
||||
## 4. Layered Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -197,7 +303,7 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
## 4. Core Data Contracts
|
||||
## 5. Core Data Contracts
|
||||
|
||||
### RunRequest
|
||||
|
||||
@@ -255,7 +361,7 @@ Checkpoint {
|
||||
|
||||
---
|
||||
|
||||
## 5. Reliability by Design
|
||||
## 6. Reliability by Design
|
||||
|
||||
### Error Taxonomy
|
||||
|
||||
@@ -308,7 +414,7 @@ def test_agent_workflow():
|
||||
|
||||
---
|
||||
|
||||
## 6. Observability & Telemetry
|
||||
## 7. Observability & Telemetry
|
||||
|
||||
### Current Telemetry Stack
|
||||
|
||||
@@ -335,7 +441,7 @@ PraisonAI already includes:
|
||||
|
||||
---
|
||||
|
||||
## 7. Replay & Checkpointing
|
||||
## 8. Replay & Checkpointing
|
||||
|
||||
### Design
|
||||
|
||||
@@ -376,7 +482,15 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Roadmap
|
||||
## 9. Implementation Roadmap
|
||||
|
||||
### Completed (C7 / C7.1 — v4.6.110)
|
||||
|
||||
| Project | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| C7 hot path | Standalone `praisonai-code run/chat/code` without wrapper import | **Complete** |
|
||||
| C7.1 boundaries | Three-tier ownership, `_wrapper_bridge`, import gates | **Complete** |
|
||||
| CI parity | Smoke standalone block + pre-existing test fixes (#2560) | **Complete** |
|
||||
|
||||
### Quarter 1: Trust Foundation (Q3 2026)
|
||||
|
||||
@@ -414,7 +528,7 @@ gantt
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
## 10. Success Metrics
|
||||
|
||||
### KPI Targets
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">last_stop_reason</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">list_mcp_servers</a>() -> List[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
|
||||
@@ -570,9 +571,11 @@ from praisonaiagents import MCP
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">apply_tool_filters</a>(allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None) -> 'MCP'</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">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>
|
||||
|
||||
# Telemetry
|
||||
|
||||
@@ -664,6 +667,8 @@ Methods:
|
||||
* <code title="class AutonomyConfig">AutonomyConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/autonomy.py">from_dict</a>(data: Dict[str, Any]) -> 'AutonomyConfig'</code>
|
||||
* <code title="class CachingConfig">CachingConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">from_dict</a>(data: Dict[str, Any]) -> 'ExecutionConfig'</code>
|
||||
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">resolved_max_steps</a>() -> int</code>
|
||||
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">resolved_max_tool_calls</a>() -> int</code>
|
||||
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class HooksConfig">HooksConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
@@ -813,7 +818,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run, run_integrated_gateway
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -821,81 +826,14 @@ from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProv
|
||||
Methods:
|
||||
|
||||
* <code title="cli">praisonai <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai acp acp-main <a href="./src/praisonai/praisonai/cli/commands/acp.py">--help</a></code>
|
||||
* <code title="cli">praisonai agent list <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
|
||||
* <code title="cli">praisonai agent show <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
|
||||
* <code title="cli">praisonai agents create <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
|
||||
* <code title="cli">praisonai attach attach-main <a href="./src/praisonai/praisonai/cli/commands/attach.py">--help</a></code>
|
||||
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth logout <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth status <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch report <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch stats <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark agent <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark benchmark-callback <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark cli <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark compare <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark litellm <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot whatsapp <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser browser-callback <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser click <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser navigate <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser open <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser profiles <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser screenshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser snapshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser status <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser type <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint delete <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint diff <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint list <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint restore <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint save <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
|
||||
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
|
||||
* <code title="cli">praisonai command list <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
|
||||
* <code title="cli">praisonai command show <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
|
||||
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion bash <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion completion-callback <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion fish <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion zsh <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai config doctor <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config env <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config get <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config list <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config reset <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config set <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config show <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config sources <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config validate <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai context add <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
@@ -905,22 +843,7 @@ Methods:
|
||||
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon daemon-main <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon start <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon status <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon stop <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai dashboard unified <a href="./src/praisonai/praisonai/cli/commands/dashboard.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug interactive <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug lsp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug trace <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai deploy aws <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
|
||||
* <code title="cli">praisonai deploy azure <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
|
||||
* <code title="cli">praisonai deploy docker <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
|
||||
* <code title="cli">praisonai deploy gcp <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
|
||||
* <code title="cli">praisonai diag diag-callback <a href="./src/praisonai/praisonai/cli/commands/diag.py">--help</a></code>
|
||||
* <code title="cli">praisonai diag export <a href="./src/praisonai/praisonai/cli/commands/diag.py">--help</a></code>
|
||||
* <code title="cli">praisonai docs api-md <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
* <code title="cli">praisonai docs generate <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
* <code title="cli">praisonai docs list <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
@@ -929,29 +852,6 @@ Methods:
|
||||
* <code title="cli">praisonai docs run-all <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
* <code title="cli">praisonai docs serve <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
* <code title="cli">praisonai docs stats <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor cleanup <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor docker <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor llm-providers <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor memory-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor metadata-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor runtime <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
|
||||
* <code title="cli">praisonai endpoints test <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
|
||||
* <code title="cli">praisonai environment check <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
|
||||
* <code title="cli">praisonai environment doctor <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
|
||||
* <code title="cli">praisonai environment view <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval accuracy <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval judge <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval list-judges <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval performance <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples find <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples info <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples list <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
@@ -964,41 +864,6 @@ Methods:
|
||||
* <code title="cli">praisonai flow import <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow list <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow version <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway doctor <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway mint-link <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway pause <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway reconnect <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway resume <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai identity import <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
|
||||
* <code title="cli">praisonai identity link <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
|
||||
* <code title="cli">praisonai identity list <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
|
||||
* <code title="cli">praisonai identity unlink <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
|
||||
* <code title="cli">praisonai init init <a href="./src/praisonai/praisonai/cli/commands/init.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban complete <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban create <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban dispatch <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban link <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban list <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban move <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
@@ -1016,13 +881,6 @@ Methods:
|
||||
* <code title="cli">praisonai langfuse test <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse traces <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse version <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop help <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop loop-main <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp logs <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp lsp-callback <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp start <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed delete <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed get <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
@@ -1046,14 +904,6 @@ Methods:
|
||||
* <code title="cli">praisonai mcp sync <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp test <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp tools <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory add <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory clear <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai models describe <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
|
||||
* <code title="cli">praisonai models list <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
|
||||
* <code title="cli">praisonai models validate <a href="./src/praisonai/praisonai/cli/commands/models.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>
|
||||
* <code title="cli">praisonai n8n list <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
@@ -1061,38 +911,6 @@ Methods:
|
||||
* <code title="cli">praisonai n8n pull <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n push <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n test <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai obs obs-fallback <a href="./src/praisonai/praisonai/cli/commands/obs.py">--help</a></code>
|
||||
* <code title="cli">praisonai onboard onboard-callback <a href="./src/praisonai/praisonai/cli/commands/onboard.py">--help</a></code>
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing approve <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing clear <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing list <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing revoke <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions allow <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions ask <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions deny <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions export <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions import-rules <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions list <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions remove <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions reset <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins doctor <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins enable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins info <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins install <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins list <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins plugins-callback <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins remove <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai port check <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
|
||||
* <code title="cli">praisonai port kill <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
|
||||
* <code title="cli">praisonai port list <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
|
||||
* <code title="cli">praisonai port port-callback <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile imports <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile optimize <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile profile-callback <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
@@ -1100,8 +918,6 @@ Methods:
|
||||
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
@@ -1117,8 +933,6 @@ Methods:
|
||||
* <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 serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai registry list <a href="./src/praisonai/praisonai/cli/commands/registry.py">--help</a></code>
|
||||
* <code title="cli">praisonai registry serve <a href="./src/praisonai/praisonai/cli/commands/registry.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>
|
||||
* <code title="cli">praisonai replay dashboard <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
@@ -1126,20 +940,7 @@ Methods:
|
||||
* <code title="cli">praisonai replay flow <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay list <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay show <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
* <code title="cli">praisonai research research-main <a href="./src/praisonai/praisonai/cli/commands/research.py">--help</a></code>
|
||||
* <code title="cli">praisonai retrieval index <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
|
||||
* <code title="cli">praisonai retrieval query <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
|
||||
* <code title="cli">praisonai retrieval search <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
|
||||
* <code title="cli">praisonai rules add <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
|
||||
* <code title="cli">praisonai rules clear <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
|
||||
* <code title="cli">praisonai rules list <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
|
||||
* <code title="cli">praisonai run <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai run run-main <a href="./src/praisonai/praisonai/cli/commands/run.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox explain <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox list <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule blueprint-list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
@@ -1156,93 +957,19 @@ 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 serve a2a <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve a2u <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve acp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve agents <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve docs <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve lsp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve mcp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve openai <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve rag <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve recipe <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve registry <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve scheduler <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve serve-callback <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve start <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session import <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session list <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup config <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup reset <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup setup-callback <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup wizard <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills check <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills learn <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills search <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills show <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.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 templates create <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
|
||||
* <code title="cli">praisonai templates list <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
|
||||
* <code title="cli">praisonai test info <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
|
||||
* <code title="cli">praisonai test interactive <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
|
||||
* <code title="cli">praisonai test run <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
|
||||
* <code title="cli">praisonai todo add <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
|
||||
* <code title="cli">praisonai todo done <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
|
||||
* <code title="cli">praisonai todo list <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
|
||||
* <code title="cli">praisonai tools info <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
|
||||
* <code title="cli">praisonai tools list <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
|
||||
* <code title="cli">praisonai tools test <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
|
||||
* <code title="cli">praisonai tools validate <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
|
||||
* <code title="cli">praisonai traces disable <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
|
||||
* <code title="cli">praisonai traces enable <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
|
||||
* <code title="cli">praisonai traces list <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
|
||||
* <code title="cli">praisonai traces status <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
|
||||
* <code title="cli">praisonai tracker batch <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
|
||||
* <code title="cli">praisonai tracker judge <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
|
||||
* <code title="cli">praisonai tracker run <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
|
||||
* <code title="cli">praisonai tracker tools <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
|
||||
* <code title="cli">praisonai tracker tracker-main <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
|
||||
* <code title="cli">praisonai train agents <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train apply <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train list <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train train-callback <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui agents <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui bot <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui realtime <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui ui <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai up logs <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
|
||||
* <code title="cli">praisonai up status <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
|
||||
* <code title="cli">praisonai up up-start <a href="./src/praisonai/praisonai/cli/commands/up.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>
|
||||
* <code title="cli">praisonai version check <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
* <code title="cli">praisonai version show <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
* <code title="cli">praisonai version version-callback <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
* <code title="cli">praisonai workflow create <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
|
||||
* <code title="cli">praisonai workflow list <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
|
||||
* <code title="cli">praisonai workflow run <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
|
||||
|
||||
# TypeScript
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.83" \
|
||||
"praisonai>=4.6.136" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.83" \
|
||||
"praisonai>=4.6.136" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.83" \
|
||||
"praisonai>=4.6.136" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -16,13 +16,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Create praison config directory
|
||||
RUN mkdir -p /root/.praison
|
||||
|
||||
# Install praisonai with bot dependencies
|
||||
# Install praisonai-bot with channel dependencies (slim bot image)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai \
|
||||
"praisonai-bot[bot]" \
|
||||
slack-bolt \
|
||||
slack-sdk \
|
||||
"discord.py>=2.0.0" \
|
||||
"python-telegram-bot>=20.0"
|
||||
slack-sdk
|
||||
|
||||
# Set environment variables for directory management
|
||||
ENV PRAISON_CONFIG_DIR=/root/.praison
|
||||
@@ -39,4 +37,4 @@ ENV DOCKER_CONTAINER=true
|
||||
# For webhook mode, expose port 3000
|
||||
|
||||
# Default command - tokens from environment
|
||||
CMD ["praisonai", "bot", "slack"]
|
||||
CMD ["praisonai-bot", "bot", "slack"]
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN}
|
||||
SLACK_APP_TOKEN: ${SLACK_APP_TOKEN}
|
||||
PRAISON_CONFIG_DIR: /root/.praison
|
||||
command: [ "praisonai", "bot", "slack" ]
|
||||
command: [ "praisonai-bot", "bot", "slack" ]
|
||||
restart: unless-stopped
|
||||
|
||||
# Discord Bot
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
|
||||
PRAISON_CONFIG_DIR: /root/.praison
|
||||
command: [ "praisonai", "bot", "discord" ]
|
||||
command: [ "praisonai-bot", "bot", "discord" ]
|
||||
restart: unless-stopped
|
||||
|
||||
# Telegram Bot
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
PRAISON_CONFIG_DIR: /root/.praison
|
||||
command: [ "praisonai", "bot", "telegram" ]
|
||||
command: [ "praisonai-bot", "bot", "telegram" ]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Standalone gateway example (praisonai-bot first).
|
||||
|
||||
Install:
|
||||
pip install praisonaiagents "praisonai-bot[gateway]"
|
||||
|
||||
Set credentials:
|
||||
export OPENAI_API_KEY=your_key
|
||||
|
||||
Run:
|
||||
python bot_standalone_example.py
|
||||
|
||||
For Telegram/Discord/Slack channels, use the CLI after onboarding:
|
||||
praisonai-bot onboard
|
||||
praisonai-bot bot start --platform telegram
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.gateway import GatewayConfig
|
||||
from praisonai_bot.gateway import WebSocketGateway
|
||||
|
||||
assistant = Agent(name="assistant", instructions="You are a helpful assistant.")
|
||||
|
||||
config = GatewayConfig(host="127.0.0.1", port=8765)
|
||||
gateway = WebSocketGateway(config=config)
|
||||
gateway.register_agent(assistant, agent_id="assistant")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Gateway: ws://127.0.0.1:8765 health: http://127.0.0.1:8765/health")
|
||||
print("CLI equivalent: praisonai-bot gateway start --host 127.0.0.1 --port 8765")
|
||||
asyncio.run(gateway.start())
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Simple test script for 24/7 Agent Scheduler
|
||||
Simple test script for 24/7 Agent Scheduler.
|
||||
|
||||
This script demonstrates the basic usage of AgentScheduler
|
||||
for running agents periodically.
|
||||
@@ -8,42 +8,56 @@ Usage:
|
||||
python simple_test.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
|
||||
from unittest.mock import Mock
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
|
||||
# Create a mock agent for testing
|
||||
mock_agent = Mock()
|
||||
# ---------------------------------------------------------------------
|
||||
# Create a mock agent
|
||||
#
|
||||
# The scheduler now supports both asynchronous (astart) and synchronous
|
||||
# (start) execution. This example exposes both methods so either dispatch
|
||||
# path can be exercised.
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
mock_agent = Mock(spec=["name", "start", "astart"])
|
||||
mock_agent.name = "TestAgent"
|
||||
mock_agent.start = Mock(return_value="Test execution successful!")
|
||||
|
||||
mock_agent.astart = AsyncMock(
|
||||
return_value="Test execution successful!"
|
||||
)
|
||||
mock_agent.start = Mock(
|
||||
return_value="Test execution successful!"
|
||||
)
|
||||
|
||||
# Create scheduler
|
||||
scheduler = AgentScheduler(
|
||||
agent=mock_agent,
|
||||
task="Test task execution"
|
||||
task="Test task execution",
|
||||
)
|
||||
|
||||
print("🤖 Testing AgentScheduler")
|
||||
print("=" * 60)
|
||||
|
||||
# Test 1: Execute once
|
||||
# Execute once
|
||||
print("\n✅ Test 1: Execute once")
|
||||
result = scheduler.execute_once()
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Test 2: Get stats
|
||||
# Statistics
|
||||
print("\n✅ Test 2: Get statistics")
|
||||
stats = scheduler.get_stats()
|
||||
print(f"Stats: {stats}")
|
||||
|
||||
# Test 3: Start scheduler (will run every 5 seconds)
|
||||
# Periodic execution
|
||||
print("\n✅ Test 3: Start scheduler (*/5s)")
|
||||
scheduler.start("*/5s", run_immediately=True)
|
||||
|
||||
import time
|
||||
print("Running for 12 seconds...")
|
||||
time.sleep(12)
|
||||
|
||||
@@ -51,11 +65,10 @@ time.sleep(12)
|
||||
print("\n✅ Test 4: Stop scheduler")
|
||||
scheduler.stop()
|
||||
|
||||
# Final stats
|
||||
# Final statistics
|
||||
print("\n📊 Final Statistics:")
|
||||
final_stats = scheduler.get_stats()
|
||||
for key, value in final_stats.items():
|
||||
for key, value in scheduler.get_stats().items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print("\n✅ All tests passed!")
|
||||
print("=" * 60)
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Standalone bot + gateway (praisonai-bot first)
|
||||
#
|
||||
# pip install praisonaiagents "praisonai-bot[gateway,bot]"
|
||||
# export OPENAI_API_KEY=...
|
||||
# praisonai-bot gateway start --host 127.0.0.1 --port 8765
|
||||
|
||||
agent:
|
||||
name: assistant
|
||||
instructions: You are a helpful assistant.
|
||||
llm: gpt-4o-mini
|
||||
|
||||
gateway:
|
||||
host: 127.0.0.1
|
||||
port: 8765
|
||||
|
||||
platforms:
|
||||
telegram:
|
||||
token: ${TELEGRAM_BOT_TOKEN}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail if praisonai_bot production code imports praisonai.* at module level (outside allowlist).
|
||||
set -euo pipefail
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT="src/praisonai-bot/praisonai_bot"
|
||||
ALLOWLIST="scripts/c9_wrapper_import_allowlist.txt"
|
||||
RE='^(from praisonai\.|import praisonai\.)'
|
||||
FAIL=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$ROOT/"}"
|
||||
if printf '%s\n' "$(sed -E 's/[[:space:]]*#.*$//;/^[[:space:]]*$/d' "$ALLOWLIST")" | grep -Fxq "$rel"; then
|
||||
continue
|
||||
fi
|
||||
if grep -E "$RE" "$f" 2>/dev/null | grep -v '^[[:space:]]*#'; then
|
||||
echo "FAIL: module-level praisonai import in $rel"
|
||||
FAIL=1
|
||||
fi
|
||||
done < <(find "$ROOT" -name '*.py' -type f)
|
||||
[ "$FAIL" -eq 0 ] && echo "audit_bot_wrapper_imports ok" || exit 1
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assert critical C8 hybrid import paths resolve (wrapper + code tiers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _check(label: str, import_fn) -> None:
|
||||
try:
|
||||
import_fn()
|
||||
except Exception as exc:
|
||||
print(f"FAIL {label}: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
print(f"OK {label}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_check(
|
||||
"praisonai.cli.features.tui.app.TUIApp",
|
||||
lambda: importlib.import_module("praisonai.cli.features.tui.app").TUIApp,
|
||||
)
|
||||
_check(
|
||||
"praisonai.cli.features.tools.ToolsHandler",
|
||||
lambda: importlib.import_module("praisonai.cli.features.tools").ToolsHandler,
|
||||
)
|
||||
_check(
|
||||
"praisonai.cli.features.workflow.WorkflowHandler",
|
||||
lambda: importlib.import_module("praisonai.cli.features.workflow").WorkflowHandler,
|
||||
)
|
||||
_check(
|
||||
"praisonai.cli.interactive.core.InteractiveCore",
|
||||
lambda: importlib.import_module("praisonai.cli.interactive.core").InteractiveCore,
|
||||
)
|
||||
_check(
|
||||
"praisonai_code.cli.interactive.async_tui.AsyncTUI",
|
||||
lambda: importlib.import_module("praisonai_code.cli.interactive.async_tui").AsyncTUI,
|
||||
)
|
||||
_check(
|
||||
"praisonai_code.cli.features.workflow.WorkflowHandler",
|
||||
lambda: importlib.import_module("praisonai_code.cli.features.workflow").WorkflowHandler,
|
||||
)
|
||||
print("All hybrid module paths importable.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
# Files allowed to contain lazy ``from praisonai.*`` imports in praisonai-code.
|
||||
# C8 complete — reverse imports eliminated; bridge-only access remains.
|
||||
# Regenerate counts: bash scripts/check_c7_imports.sh
|
||||
#
|
||||
# If a new direct import is unavoidable, add path below after Architecture Review.
|
||||
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env python3
|
||||
"""C8.4 — mechanical main.py decomposition into legacy modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
MAIN = REPO / "src/praisonai-code/praisonai_code/cli/main.py"
|
||||
CODE_LEGACY = REPO / "src/praisonai-code/praisonai_code/cli/legacy"
|
||||
WRAP_LEGACY = REPO / "src/praisonai/praisonai/cli/legacy"
|
||||
PRAISON_AI = CODE_LEGACY / "praison_ai.py"
|
||||
THIN_MAIN = MAIN
|
||||
|
||||
# (start, end) 1-indexed inclusive
|
||||
RANGES = {
|
||||
"env_security.py": (45, 92),
|
||||
"subcommand_handlers.py": [
|
||||
(2342, 2518), # memory
|
||||
(2520, 2631), # rules
|
||||
(3310, 3392), # hooks
|
||||
(3394, 3479), # knowledge (stop before session)
|
||||
(3535, 3647), # docs
|
||||
(3649, 3808), # mcp
|
||||
(3810, 3907), # sensitive patterns + check + clean_commit
|
||||
(3909, 4119), # commit
|
||||
(5761, 5803), # context
|
||||
(5805, 6010), # research
|
||||
],
|
||||
"workflow_commands.py": (2633, 3308),
|
||||
"direct_prompt.py": [
|
||||
(2087, 2340),
|
||||
(4121, 4148), # _save_output
|
||||
(4421, 5385),
|
||||
],
|
||||
"dispatch/argparse_builder.py": (1074, 1359),
|
||||
"interactive_legacy.py": (6012, 7486),
|
||||
}
|
||||
|
||||
# Lines to remove from praison_ai (replaced by delegation)
|
||||
EXTRACT_LINES = set()
|
||||
for spec in RANGES.values():
|
||||
blocks = spec if isinstance(spec, list) else [spec]
|
||||
for start, end in blocks:
|
||||
EXTRACT_LINES.update(range(start, end + 1))
|
||||
|
||||
DELEGATE_METHODS = {
|
||||
"subcommand_handlers": [
|
||||
"handle_memory_command",
|
||||
"handle_rules_command",
|
||||
"handle_hooks_command",
|
||||
"handle_knowledge_command",
|
||||
"handle_docs_command",
|
||||
"handle_mcp_command",
|
||||
"handle_commit_command",
|
||||
"handle_context_command",
|
||||
"handle_research_command",
|
||||
"_check_sensitive_content",
|
||||
"_clean_commit_message",
|
||||
"_get_sensitive_patterns",
|
||||
],
|
||||
"workflow_commands": [
|
||||
"handle_workflow_command",
|
||||
"_run_yaml_workflow",
|
||||
"_validate_yaml_workflow",
|
||||
"_get_canonical_suggestions",
|
||||
"_create_workflow_from_template",
|
||||
"_auto_generate_workflow",
|
||||
],
|
||||
"direct_prompt": [
|
||||
"_rewrite_query",
|
||||
"_rewrite_query_if_enabled",
|
||||
"_expand_prompt",
|
||||
"_expand_prompt_if_enabled",
|
||||
"_load_tools",
|
||||
"_load_toolsets",
|
||||
"_save_output",
|
||||
"handle_direct_prompt",
|
||||
],
|
||||
"interactive_legacy": [
|
||||
"_start_interactive_mode",
|
||||
"_load_interactive_tools",
|
||||
"_run_chat_mode",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def read_lines() -> list[str]:
|
||||
return MAIN.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
|
||||
|
||||
def dedent_block(lines: list[str], spaces: int = 4) -> list[str]:
|
||||
out: list[str] = []
|
||||
prefix = " " * spaces
|
||||
for line in lines:
|
||||
if line.startswith(prefix):
|
||||
out.append(line[spaces:])
|
||||
else:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def extract_range(all_lines: list[str], start: int, end: int) -> list[str]:
|
||||
return dedent_block(all_lines[start - 1 : end])
|
||||
|
||||
|
||||
def transform_sensitive_patterns(lines: list[str]) -> list[str]:
|
||||
text = "".join(lines)
|
||||
text = text.replace("cls._SENSITIVE_PATTERNS", "_SENSITIVE_PATTERNS")
|
||||
text = re.sub(
|
||||
r"@classmethod\s*\n\s*def _get_sensitive_patterns\(cls\):",
|
||||
"def _get_sensitive_patterns():",
|
||||
text,
|
||||
)
|
||||
text = text.replace(" _SENSITIVE_PATTERNS = None\n", "_SENSITIVE_PATTERNS = None\n")
|
||||
text = text.replace(" _SENSITIVE_FILES = ", "_SENSITIVE_FILES = ")
|
||||
text = text.replace(" _SENSITIVE_EXTENSIONS = ", "_SENSITIVE_EXTENSIONS = ")
|
||||
text = text.replace("self._get_sensitive_patterns()", "_get_sensitive_patterns()")
|
||||
text = re.sub(
|
||||
r"@staticmethod\s*\n\s*def _clean_commit_message",
|
||||
"def _clean_commit_message",
|
||||
text,
|
||||
)
|
||||
return text.splitlines(keepends=True)
|
||||
|
||||
|
||||
def module_header(doc: str, extra_imports: str = "") -> str:
|
||||
return textwrap.dedent(
|
||||
f'''\
|
||||
"""{doc}"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
from rich import print
|
||||
{extra_imports}
|
||||
'''
|
||||
)
|
||||
|
||||
|
||||
def write_env_security(all_lines: list[str]) -> None:
|
||||
body = all_lines[44:92] # module-level — no dedent
|
||||
header = '"""Environment variable security (C8.4)."""\n\nfrom __future__ import annotations\n\nfrom dotenv import load_dotenv\n\n'
|
||||
path = CODE_LEGACY / "env_security.py"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(header + "".join(body), encoding="utf-8")
|
||||
|
||||
|
||||
def write_subcommand_handlers(all_lines: list[str]) -> None:
|
||||
parts: list[str] = []
|
||||
for start, end in RANGES["subcommand_handlers.py"]: # type: ignore[index]
|
||||
chunk = extract_range(all_lines, start, end)
|
||||
if start == 3810:
|
||||
chunk = transform_sensitive_patterns(chunk)
|
||||
parts.append("".join(chunk))
|
||||
content = module_header("CLI subcommand handlers (C8.4).") + "\n".join(parts)
|
||||
path = WRAP_LEGACY / "subcommand_handlers.py"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def write_workflow_commands(all_lines: list[str]) -> None:
|
||||
start, end = RANGES["workflow_commands.py"] # type: ignore[misc]
|
||||
body = extract_range(all_lines, start, end)
|
||||
content = module_header("Workflow CLI commands (C8.4).") + "".join(body)
|
||||
path = WRAP_LEGACY / "workflow_commands.py"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def write_direct_prompt(all_lines: list[str]) -> None:
|
||||
parts: list[str] = []
|
||||
for start, end in RANGES["direct_prompt.py"]: # type: ignore[index]
|
||||
parts.append("".join(extract_range(all_lines, start, end)))
|
||||
content = module_header("Direct prompt execution (C8.4).") + "\n".join(parts)
|
||||
path = WRAP_LEGACY / "direct_prompt.py"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def write_argparse_builder(all_lines: list[str]) -> None:
|
||||
body = dedent_block(all_lines[1073:1359], spaces=8)
|
||||
fn = textwrap.dedent(
|
||||
'''\
|
||||
|
||||
def build_argument_parser(in_test_env: bool):
|
||||
"""Build ArgumentParser and parse argv. Returns (args, unknown_args, special_commands)."""
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths', 'claw', 'github', 'managed', 'flow', 'dashboard', 'backends', 'audit']
|
||||
|
||||
'''
|
||||
)
|
||||
# Replace first two lines (special_commands + blank) from body — body starts with special_commands
|
||||
body_text = "".join(body)
|
||||
body_text = re.sub(
|
||||
r"^\s*special_commands = \[.*?\]\s*\n\s*\n",
|
||||
"",
|
||||
body_text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
indented = textwrap.indent(body_text, " ")
|
||||
parse_tail = textwrap.indent(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
if in_test_env:
|
||||
args, unknown_args = parser.parse_known_args([])
|
||||
else:
|
||||
args, unknown_args = parser.parse_known_args()
|
||||
return args, unknown_args, special_commands
|
||||
"""
|
||||
),
|
||||
" ",
|
||||
)
|
||||
extra = "from praisonai.cli.legacy.framework_run import fw_registry_module as _fw_registry_module\n"
|
||||
content = (
|
||||
module_header("ArgumentParser schema for legacy CLI (C8.4).", extra)
|
||||
+ fn
|
||||
+ indented
|
||||
+ parse_tail
|
||||
)
|
||||
path = WRAP_LEGACY / "dispatch" / "argparse_builder.py"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def write_interactive_legacy(all_lines: list[str]) -> None:
|
||||
start, end = RANGES["interactive_legacy.py"] # type: ignore[misc]
|
||||
body = extract_range(all_lines, start, end)
|
||||
content = module_header("Interactive REPL/TUI legacy path (C8.4).") + "".join(body)
|
||||
path = WRAP_LEGACY / "interactive_legacy.py"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def make_delegate(module_suffix: str, method: str) -> str:
|
||||
mod = f"praisonai.cli.legacy.{module_suffix}"
|
||||
return (
|
||||
f" def {method}(self, *args, **kwargs):\n"
|
||||
f' from praisonai_code._wrapper_bridge import import_wrapper_module\n'
|
||||
f' _mod = import_wrapper_module("{mod}")\n'
|
||||
f' return getattr(_mod, "{method}")(self, *args, **kwargs)\n\n'
|
||||
)
|
||||
|
||||
|
||||
def write_praison_ai(all_lines: list[str]) -> None:
|
||||
kept: list[str] = []
|
||||
for i, line in enumerate(all_lines):
|
||||
line_no = i + 1
|
||||
if line_no < 347:
|
||||
continue
|
||||
if line_no in EXTRACT_LINES:
|
||||
continue
|
||||
kept.append(line)
|
||||
|
||||
text = "".join(kept)
|
||||
|
||||
# Replace argparse builder block inside parse_args
|
||||
text = re.sub(
|
||||
r" # Define special commands\n special_commands = \[.*?"
|
||||
r" args, unknown_args = parser\.parse_known_args\(\)\n\n",
|
||||
" from praisonai_code._wrapper_bridge import import_wrapper_module\n"
|
||||
" _ab = import_wrapper_module(\"praisonai.cli.legacy.dispatch.argparse_builder\")\n"
|
||||
" args, unknown_args, special_commands = _ab.build_argument_parser(in_test_env)\n\n",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Remove dead branches in special_commands block (already handled earlier)
|
||||
text = re.sub(
|
||||
r"\n elif args\.command == 'call':\n"
|
||||
r" import importlib\.util\n.*?"
|
||||
r" sys\.exit\(0\)\n",
|
||||
"\n",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
text = re.sub(
|
||||
r"\n elif args\.command == 'serve':\n"
|
||||
r" # Serve command.*?\n sys\.exit\(exit_code\)\n",
|
||||
"\n",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
text = re.sub(
|
||||
r"\n elif args\.command == 'replay':\n"
|
||||
r" # Replay command.*?\n sys\.exit\(0\)\n",
|
||||
"\n",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
delegates = ""
|
||||
for mod, methods in DELEGATE_METHODS.items():
|
||||
for method in methods:
|
||||
delegates += make_delegate(mod, method)
|
||||
|
||||
# Insert delegates at end of PraisonAI class (before trailing module-level code)
|
||||
text = text.rstrip() + "\n\n" + delegates
|
||||
|
||||
header = textwrap.dedent(
|
||||
'''\
|
||||
"""PraisonAI CLI class — C8.4 legacy shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import warnings
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
import time
|
||||
from rich import print
|
||||
from dotenv import load_dotenv
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import importlib
|
||||
|
||||
from praisonai_code._version import get_package_version
|
||||
from praisonai_code._framework_availability import is_available
|
||||
from praisonai_code._logging import configure_cli_logging
|
||||
from .legacy.env_security import (
|
||||
_BLOCKED_ENV_KEYS,
|
||||
_BLOCKED_ENV_KEYS_UPPER,
|
||||
_load_env_once,
|
||||
_validate_env_key,
|
||||
)
|
||||
|
||||
__version__ = get_package_version()
|
||||
|
||||
if os.environ.get('LOGLEVEL', 'INFO').upper() != 'DEBUG':
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=".*found in sys.modules after import of package.*",
|
||||
category=RuntimeWarning
|
||||
)
|
||||
|
||||
'''
|
||||
)
|
||||
|
||||
helpers = "".join(all_lines[94:173]) + "".join(all_lines[228:346])
|
||||
helpers = helpers.replace(
|
||||
'return import_wrapper_module("praisonai.inbuilt_tools")',
|
||||
'return import_wrapper_module("praisonai.cli.legacy.inbuilt_tools")',
|
||||
)
|
||||
avail_section = "".join(all_lines[174:227])
|
||||
|
||||
praison_content = header + helpers + avail_section + text
|
||||
PRAISON_AI.parent.mkdir(parents=True, exist_ok=True)
|
||||
PRAISON_AI.write_text(praison_content, encoding="utf-8")
|
||||
|
||||
|
||||
def write_thin_main() -> None:
|
||||
content = textwrap.dedent(
|
||||
'''\
|
||||
# praisonai/cli/main.py — thin re-export layer (C8.4)
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import warnings
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import importlib
|
||||
|
||||
from ._warnings import install_warning_filters, _uninstall_warning_filters, _SUPPRESSED_PATTERNS
|
||||
from praisonai_code._version import get_package_version
|
||||
|
||||
__version__ = get_package_version()
|
||||
|
||||
if os.environ.get('LOGLEVEL', 'INFO').upper() != 'DEBUG':
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=".*found in sys.modules after import of package.*",
|
||||
category=RuntimeWarning
|
||||
)
|
||||
|
||||
from .legacy.env_security import (
|
||||
_BLOCKED_ENV_KEYS,
|
||||
_BLOCKED_ENV_KEYS_UPPER,
|
||||
_load_env_once,
|
||||
_validate_env_key,
|
||||
)
|
||||
from .legacy.praison_ai import PraisonAI
|
||||
|
||||
from praisonai_code._framework_availability import is_available
|
||||
|
||||
_AVAILABILITY_FLAGS = {
|
||||
"GRADIO_AVAILABLE": "gradio",
|
||||
"CREWAI_AVAILABLE": "crewai",
|
||||
"AUTOGEN_AVAILABLE": "autogen",
|
||||
"PRAISONAI_AVAILABLE": "praisonaiagents",
|
||||
"TRAIN_AVAILABLE": "unsloth",
|
||||
}
|
||||
|
||||
|
||||
def _compute_availability_flag(name):
|
||||
if name in _AVAILABILITY_FLAGS:
|
||||
return is_available(_AVAILABILITY_FLAGS[name])
|
||||
if name == "CALL_MODULE_AVAILABLE":
|
||||
try:
|
||||
import importlib.util
|
||||
return importlib.util.find_spec("praisonai.api.call") is not None
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
return False
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def _ensure_availability_flags():
|
||||
g = globals()
|
||||
for flag in (*_AVAILABILITY_FLAGS, "CALL_MODULE_AVAILABLE"):
|
||||
if flag not in g:
|
||||
g[flag] = _compute_availability_flag(flag)
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in _AVAILABILITY_FLAGS or name == "CALL_MODULE_AVAILABLE":
|
||||
value = _compute_availability_flag(name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def _get_inbuilt_tools():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
return import_wrapper_module("praisonai.cli.legacy.inbuilt_tools")
|
||||
|
||||
|
||||
def _get_generate_config():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
_mod = import_wrapper_module("praisonai.inc.config")
|
||||
return getattr(_mod, "generate_config")
|
||||
|
||||
|
||||
def _get_auto_generator():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
_mod = import_wrapper_module("praisonai.auto")
|
||||
return getattr(_mod, "AutoGenerator")
|
||||
|
||||
|
||||
def _get_agents_generator():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
_mod = import_wrapper_module("praisonai.agents_generator")
|
||||
return getattr(_mod, "AgentsGenerator")
|
||||
|
||||
|
||||
def _get_call_module():
|
||||
import importlib.util
|
||||
if not importlib.util.find_spec("praisonai.api.call"):
|
||||
raise ImportError(
|
||||
'Call feature is not installed. Install with: pip install "praisonai[call]"'
|
||||
)
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
_mod = import_wrapper_module("praisonai.api")
|
||||
return getattr(_mod, "call")
|
||||
|
||||
|
||||
def _fw_registry_module():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
return import_wrapper_module("praisonai.framework_adapters.registry")
|
||||
|
||||
|
||||
def _fw_validators_module():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
return import_wrapper_module("praisonai.framework_adapters.validators")
|
||||
|
||||
|
||||
def _fw_workflow_module():
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
return import_wrapper_module("praisonai.framework_adapters.workflow_framework")
|
||||
|
||||
|
||||
def _provider_preflight_message():
|
||||
try:
|
||||
from praisonai_code.llm.credentials import inject_credentials_into_env, is_configured
|
||||
inject_credentials_into_env()
|
||||
runtime_model = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME")
|
||||
if is_configured(model=runtime_model):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return (
|
||||
"No LLM provider is configured.\\n\\n"
|
||||
"PraisonAI supports OpenAI, Anthropic, Google/Gemini, Groq, "
|
||||
"Cohere, Ollama, OpenRouter and 100+ models via LiteLLM.\\n\\n"
|
||||
"Easiest setup (interactive, no shell 'export' needed):\\n"
|
||||
" praisonai setup\\n\\n"
|
||||
"Or set a provider API key, for example:\\n"
|
||||
" export OPENAI_API_KEY=... # OpenAI\\n"
|
||||
" export ANTHROPIC_API_KEY=... # Anthropic Claude\\n"
|
||||
" export GEMINI_API_KEY=... # Google Gemini\\n"
|
||||
)
|
||||
|
||||
|
||||
def stream_subprocess(command, env=None):
|
||||
"""Execute a subprocess and stream output to the terminal."""
|
||||
import subprocess
|
||||
from rich import print
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
env=env,
|
||||
)
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
print(line, end='')
|
||||
sys.stdout.flush()
|
||||
process.stdout.close()
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
raise subprocess.CalledProcessError(return_code, command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
install_warning_filters()
|
||||
praison_ai = PraisonAI()
|
||||
praison_ai.main()
|
||||
'''
|
||||
)
|
||||
THIN_MAIN.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
all_lines = read_lines()
|
||||
write_env_security(all_lines)
|
||||
write_subcommand_handlers(all_lines)
|
||||
write_workflow_commands(all_lines)
|
||||
write_direct_prompt(all_lines)
|
||||
write_argparse_builder(all_lines)
|
||||
write_interactive_legacy(all_lines)
|
||||
write_praison_ai(all_lines)
|
||||
write_thin_main()
|
||||
print("C8.4 decomposition complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-API smoke tests for C8.4 main.py decomposition paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
MODEL = os.environ.get("TEST_MODEL", os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
seconds: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Suite:
|
||||
results: list[Result] = field(default_factory=list)
|
||||
|
||||
def add(self, r: Result) -> None:
|
||||
self.results.append(r)
|
||||
mark = "PASS" if r.ok else "FAIL"
|
||||
print(f"[{mark}] {r.name} ({r.seconds:.1f}s) — {r.detail[:140]}")
|
||||
|
||||
def exit_code(self) -> int:
|
||||
passed = sum(1 for r in self.results if r.ok)
|
||||
failed = len(self.results) - passed
|
||||
print("\n" + "=" * 72)
|
||||
print(f"C8.4 Real-API Smoke: {passed}/{len(self.results)} passed, {failed} failed")
|
||||
for r in self.results:
|
||||
if not r.ok:
|
||||
print(f" FAIL: {r.name}: {r.detail[:200]}")
|
||||
print("=" * 72)
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def env_wrapper() -> dict[str, str]:
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST_")}
|
||||
agents = str(REPO / "src" / "praisonai-agents")
|
||||
code = str(REPO / "src" / "praisonai-code")
|
||||
wrapper = str(REPO / "src" / "praisonai")
|
||||
base["PYTHONPATH"] = f"{agents}{os.pathsep}{code}{os.pathsep}{wrapper}"
|
||||
base.setdefault("LOGLEVEL", "WARNING")
|
||||
return base
|
||||
|
||||
|
||||
def env_code_only() -> dict[str, str]:
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST_")}
|
||||
agents = str(REPO / "src" / "praisonai-agents")
|
||||
code = str(REPO / "src" / "praisonai-code")
|
||||
base["PYTHONPATH"] = f"{agents}{os.pathsep}{code}"
|
||||
base.setdefault("LOGLEVEL", "WARNING")
|
||||
return base
|
||||
|
||||
|
||||
def run_py(script: str, *, env: dict[str, str], timeout: int = 120, expect: str | None = None) -> tuple[bool, str]:
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
out = (p.stdout or "") + (p.stderr or "")
|
||||
ok = p.returncode == 0
|
||||
if expect and expect not in out:
|
||||
ok = False
|
||||
out += f"\n(missing {expect!r})"
|
||||
return ok, out.strip()[-600:]
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"timeout after {timeout}s"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def run_cmd(cmd: list[str], *, env: dict[str, str], timeout: int = 120, expect: str | None = None) -> tuple[bool, str]:
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=str(REPO), env=env, capture_output=True, text=True, timeout=timeout)
|
||||
out = (p.stdout or "") + (p.stderr or "")
|
||||
ok = p.returncode == 0
|
||||
if expect and expect not in out:
|
||||
ok = False
|
||||
out += f"\n(missing {expect!r})"
|
||||
return ok, out.strip()[-600:]
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"timeout after {timeout}s"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY not set (source ~/.zshrc or ~/.bashrc)")
|
||||
return 1
|
||||
|
||||
suite = Suite()
|
||||
wenv = env_wrapper()
|
||||
cenv = env_code_only()
|
||||
py = sys.executable
|
||||
|
||||
print("=" * 72)
|
||||
print("C8.4 Real-API Smoke — delegate paths, legacy CLI, Typer hot path")
|
||||
print(f"Model: {MODEL}")
|
||||
print("=" * 72)
|
||||
|
||||
# --- Module identity & delegates (no LLM) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_py(
|
||||
"""
|
||||
import praisonai.cli.main as old
|
||||
import praisonai_code.cli.main as new
|
||||
from praisonai.cli.main import PraisonAI
|
||||
assert old is new
|
||||
assert hasattr(PraisonAI, 'handle_direct_prompt')
|
||||
assert hasattr(PraisonAI, '_start_interactive_mode')
|
||||
assert hasattr(PraisonAI, 'handle_memory_command')
|
||||
print('IDENTITY_OK')
|
||||
""",
|
||||
env=wenv,
|
||||
expect="IDENTITY_OK",
|
||||
)
|
||||
suite.add(Result("C5 module identity + C8.4 delegates", ok, detail, time.time() - t0))
|
||||
|
||||
# --- direct_prompt delegate (real LLM) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_py(
|
||||
f"""
|
||||
import os
|
||||
os.environ['LOGLEVEL'] = 'ERROR'
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import argparse
|
||||
p = PraisonAI()
|
||||
p.args = argparse.Namespace(
|
||||
llm={MODEL!r}, verbose=0, memory=False, planning=False, workflow=None,
|
||||
profile=False, no_tools=True, toolset=None, web=False, stream=False,
|
||||
query_rewrite=False, expand_prompt=False, output_format=None,
|
||||
)
|
||||
out = p.handle_direct_prompt('Reply with exactly: C84_DIRECT_OK')
|
||||
text = str(out)
|
||||
print(text)
|
||||
assert 'C84_DIRECT_OK' in text, text[:200]
|
||||
print('DIRECT_OK')
|
||||
""",
|
||||
env=wenv,
|
||||
timeout=90,
|
||||
expect="DIRECT_OK",
|
||||
)
|
||||
suite.add(Result("handle_direct_prompt delegate (legacy import path)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- praison_ai direct (code module path) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_py(
|
||||
f"""
|
||||
import os
|
||||
os.environ['LOGLEVEL'] = 'ERROR'
|
||||
from praisonai_code.cli.legacy.praison_ai import PraisonAI
|
||||
import argparse
|
||||
p = PraisonAI()
|
||||
p.args = argparse.Namespace(
|
||||
llm={MODEL!r}, verbose=0, memory=False, planning=False, workflow=None,
|
||||
profile=False, no_tools=True, toolset=None, web=False, stream=False,
|
||||
query_rewrite=False, expand_prompt=False, output_format=None,
|
||||
)
|
||||
out = p.handle_direct_prompt('Reply with exactly: C84_PRAISON_AI_OK')
|
||||
assert 'C84_PRAISON_AI_OK' in str(out)
|
||||
print('PRAISON_AI_OK')
|
||||
""",
|
||||
env=wenv,
|
||||
timeout=90,
|
||||
expect="PRAISON_AI_OK",
|
||||
)
|
||||
suite.add(Result("handle_direct_prompt via praison_ai.py", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Typer run (code tier standalone) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai_code", "run", "-m", MODEL, "Reply exactly: C84_CODE_RUN_OK"],
|
||||
env=cenv,
|
||||
timeout=90,
|
||||
expect="C84_CODE_RUN_OK",
|
||||
)
|
||||
suite.add(Result("Typer praisonai-code run (standalone)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Typer code command (single prompt) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai_code", "code", "-m", MODEL, "Reply exactly: C84_CODE_CODE_OK"],
|
||||
env=cenv,
|
||||
timeout=90,
|
||||
expect="C84_CODE_CODE_OK",
|
||||
)
|
||||
suite.add(Result("Typer praisonai-code code (standalone)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Typer run (wrapper) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai", "run", "-m", MODEL, "Reply exactly: C84_WRAPPER_RUN_OK"],
|
||||
env=wenv,
|
||||
timeout=90,
|
||||
expect="C84_WRAPPER_RUN_OK",
|
||||
)
|
||||
suite.add(Result("Typer praisonai run (wrapper)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Legacy CLI main direct prompt ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai.cli.main", "--llm", MODEL, "--no-tools", "Reply exactly: C84_LEGACY_MAIN_OK"],
|
||||
env=wenv,
|
||||
timeout=90,
|
||||
expect="C84_LEGACY_MAIN_OK",
|
||||
)
|
||||
suite.add(Result("Legacy python -m praisonai.cli.main prompt", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Memory handler delegate (no LLM) ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai", "memory", "show"],
|
||||
env=wenv,
|
||||
timeout=60,
|
||||
)
|
||||
suite.add(Result("memory show (subcommand_handlers delegate)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Workflow list delegate ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai", "workflow", "list"],
|
||||
env=wenv,
|
||||
timeout=60,
|
||||
)
|
||||
suite.add(Result("workflow list (workflow_commands delegate)", ok, detail, time.time() - t0))
|
||||
|
||||
# --- Legacy YAML multi-agent (uses praison_ai main flow) ---
|
||||
t0 = time.time()
|
||||
yaml_content = textwrap.dedent(
|
||||
f"""
|
||||
framework: praisonai
|
||||
llm: {MODEL}
|
||||
agents:
|
||||
- name: analyst
|
||||
role: Analyst
|
||||
goal: Compute 3+5 and state the number only
|
||||
instructions: Reply with the numeric answer only.
|
||||
- name: writer
|
||||
role: Writer
|
||||
goal: Summarise analyst output ending with C84_YAML_OK
|
||||
instructions: One sentence summary ending with C84_YAML_OK
|
||||
tasks:
|
||||
- description: What is 3+5?
|
||||
agent: analyst
|
||||
- description: Summarise the analyst result. End with C84_YAML_OK.
|
||||
agent: writer
|
||||
"""
|
||||
).strip()
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
ypath = f.name
|
||||
try:
|
||||
ok, detail = run_cmd(
|
||||
[py, "-m", "praisonai.cli.main", "--framework", "praisonai", ypath],
|
||||
env=wenv,
|
||||
timeout=180,
|
||||
expect="C84_YAML_OK",
|
||||
)
|
||||
if not ok and re.search(r"\b8\b", detail):
|
||||
ok = True
|
||||
detail = "YAML workflow produced numeric output"
|
||||
suite.add(Result("Legacy YAML via praison_ai.main()", ok, detail, time.time() - t0))
|
||||
finally:
|
||||
Path(ypath).unlink(missing_ok=True)
|
||||
|
||||
# --- Gates ---
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(["bash", "scripts/check_main_py_lines.sh"], env=wenv)
|
||||
suite.add(Result("check_main_py_lines.sh", ok, detail, time.time() - t0))
|
||||
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(["bash", "scripts/check_c7_imports.sh"], env=wenv)
|
||||
suite.add(Result("check_c7_imports.sh", ok, detail, time.time() - t0))
|
||||
|
||||
return suite.exit_code()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert direct ``from praisonai`` imports to bridge calls in a file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Match: from praisonai.foo.bar import A, B
|
||||
FROM_IMPORT = re.compile(
|
||||
r"^(\s*)from praisonai(\.[\w.]+)? import (.+)$"
|
||||
)
|
||||
# Match: from praisonai import foo
|
||||
FROM_ROOT = re.compile(
|
||||
r"^(\s*)from praisonai import (.+)$"
|
||||
)
|
||||
|
||||
|
||||
def _convert_from_line(line: str) -> str | None:
|
||||
m = FROM_ROOT.match(line)
|
||||
if m:
|
||||
indent, names = m.groups()
|
||||
mod = "praisonai"
|
||||
return (
|
||||
f"{indent}from praisonai_code._wrapper_bridge import import_wrapper_module\n"
|
||||
f"{indent}_mod = import_wrapper_module({mod!r})\n"
|
||||
f"{indent}({names}) = tuple(getattr(_mod, n.strip()) for n in {names!r}.split(','))"
|
||||
)
|
||||
m = FROM_IMPORT.match(line)
|
||||
if not m:
|
||||
return None
|
||||
indent, suffix, names = m.groups()
|
||||
mod = f"praisonai{suffix or ''}"
|
||||
# Handle ``import x as y``
|
||||
parts = [p.strip() for p in names.split(",")]
|
||||
assigns = []
|
||||
for part in parts:
|
||||
if " as " in part:
|
||||
orig, alias = part.split(" as ", 1)
|
||||
orig, alias = orig.strip(), alias.strip()
|
||||
assigns.append(f"{alias} = getattr(_mod, {orig!r})")
|
||||
else:
|
||||
assigns.append(f"{part} = getattr(_mod, {part!r})")
|
||||
body = "\n".join(f"{indent}{a}" for a in assigns)
|
||||
return (
|
||||
f"{indent}from praisonai_code._wrapper_bridge import import_wrapper_module\n"
|
||||
f"{indent}_mod = import_wrapper_module({mod!r})\n"
|
||||
f"{body}"
|
||||
)
|
||||
|
||||
|
||||
def convert_file(path: Path) -> int:
|
||||
text = path.read_text()
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
changed = 0
|
||||
for line in lines:
|
||||
if line.strip().startswith("#") and "from praisonai" in line:
|
||||
out.append(line)
|
||||
continue
|
||||
if "from praisonai" in line and "praisonai_code" not in line and "praisonaiagents" not in line:
|
||||
new = _convert_from_line(line)
|
||||
if new:
|
||||
out.append(new)
|
||||
changed += 1
|
||||
continue
|
||||
out.append(line)
|
||||
if changed:
|
||||
path.write_text("\n".join(out) + ("\n" if text.endswith("\n") else ""))
|
||||
return changed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
total = 0
|
||||
for p in sys.argv[1:]:
|
||||
n = convert_file(Path(p))
|
||||
print(f"{p}: {n} lines converted")
|
||||
total += n
|
||||
print(f"total: {total}")
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""In-depth real-API smoke tests for all three PraisonAI tiers (post C8 release)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
ENV_FILE = REPO / ".env"
|
||||
MODEL = os.environ.get("TEST_MODEL", "gpt-4o-mini")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
tier: str
|
||||
ok: bool
|
||||
detail: str
|
||||
seconds: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Suite:
|
||||
results: list[Result] = field(default_factory=list)
|
||||
|
||||
def add(self, r: Result) -> None:
|
||||
self.results.append(r)
|
||||
mark = "PASS" if r.ok else "FAIL"
|
||||
print(f"[{mark}] [{r.tier}] {r.name} ({r.seconds:.1f}s) — {r.detail[:120]}")
|
||||
|
||||
def summary(self) -> int:
|
||||
passed = sum(1 for r in self.results if r.ok)
|
||||
failed = len(self.results) - passed
|
||||
print("\n" + "=" * 72)
|
||||
print(f"TOTAL: {passed}/{len(self.results)} passed, {failed} failed")
|
||||
for r in self.results:
|
||||
if not r.ok:
|
||||
print(f" FAIL: [{r.tier}] {r.name}: {r.detail}")
|
||||
print("=" * 72)
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def has_openai() -> bool:
|
||||
return bool(os.environ.get("OPENAI_API_KEY"))
|
||||
|
||||
|
||||
def run_cmd(
|
||||
cmd: list[str],
|
||||
*,
|
||||
env: dict[str, str],
|
||||
timeout: int = 120,
|
||||
expect_in_output: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
out = (p.stdout or "") + (p.stderr or "")
|
||||
ok = p.returncode == 0
|
||||
if expect_in_output and expect_in_output not in out:
|
||||
ok = False
|
||||
out += f"\n(missing expected substring: {expect_in_output!r})"
|
||||
if not ok and p.returncode != 0:
|
||||
out += f"\n(exit {p.returncode})"
|
||||
return ok, out.strip()[-800:]
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"timeout after {timeout}s"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def tier_env(tier: str) -> dict[str, str]:
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST_")}
|
||||
agents = str(REPO / "src" / "praisonai-agents")
|
||||
code = str(REPO / "src" / "praisonai-code")
|
||||
wrapper = str(REPO / "src" / "praisonai")
|
||||
if tier == "sdk":
|
||||
base["PYTHONPATH"] = agents
|
||||
elif tier == "code":
|
||||
base["PYTHONPATH"] = f"{agents}{os.pathsep}{code}"
|
||||
elif tier == "wrapper":
|
||||
base["PYTHONPATH"] = f"{agents}{os.pathsep}{code}{os.pathsep}{wrapper}"
|
||||
else:
|
||||
raise ValueError(tier)
|
||||
return base
|
||||
|
||||
|
||||
def test_sdk_single_agent(suite: Suite) -> None:
|
||||
t0 = time.time()
|
||||
sys.path.insert(0, str(REPO / "src" / "praisonai-agents"))
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="smoke",
|
||||
instructions="Reply with exactly the token given. No extra words.",
|
||||
llm=MODEL,
|
||||
)
|
||||
out = agent.start("Reply with exactly: SDK_SINGLE_OK")
|
||||
text = str(out).strip()
|
||||
ok = "SDK_SINGLE_OK" in text
|
||||
suite.add(Result("single Agent.start()", "sdk", ok, text[:200], time.time() - t0))
|
||||
except Exception as e:
|
||||
suite.add(Result("single Agent.start()", "sdk", False, str(e), time.time() - t0))
|
||||
finally:
|
||||
if sys.path[0].endswith("praisonai-agents"):
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def test_sdk_multi_agent_team(suite: Suite) -> None:
|
||||
t0 = time.time()
|
||||
sys.path.insert(0, str(REPO / "src" / "praisonai-agents"))
|
||||
try:
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
researcher = Agent(
|
||||
name="researcher",
|
||||
instructions="You are a researcher. Answer in one short sentence with a number.",
|
||||
llm=MODEL,
|
||||
)
|
||||
writer = Agent(
|
||||
name="writer",
|
||||
instructions="You are a writer. Use the prior task output. End with exactly: TEAM_OK",
|
||||
llm=MODEL,
|
||||
)
|
||||
t1 = Task(
|
||||
name="research",
|
||||
description="What is 2+2? Reply with just the number.",
|
||||
agent=researcher,
|
||||
)
|
||||
t2 = Task(
|
||||
name="write",
|
||||
description="Say 'Result is' plus the research answer. End with TEAM_OK.",
|
||||
agent=writer,
|
||||
)
|
||||
team = AgentTeam(agents=[researcher, writer], tasks=[t1, t2])
|
||||
out = team.start()
|
||||
text = str(out).strip()
|
||||
ok = "TEAM_OK" in text or "4" in text
|
||||
suite.add(Result("AgentTeam 2-agent workflow", "sdk", ok, text[:300], time.time() - t0))
|
||||
except Exception as e:
|
||||
suite.add(Result("AgentTeam 2-agent workflow", "sdk", False, str(e), time.time() - t0))
|
||||
finally:
|
||||
if sys.path[0].endswith("praisonai-agents"):
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def test_sdk_handoff(suite: Suite) -> None:
|
||||
t0 = time.time()
|
||||
sys.path.insert(0, str(REPO / "src" / "praisonai-agents"))
|
||||
try:
|
||||
from praisonaiagents import Agent, handoff
|
||||
|
||||
billing = Agent(
|
||||
name="billing",
|
||||
instructions="Billing agent. If asked about billing, reply: BILLING_HANDOFF_OK",
|
||||
llm=MODEL,
|
||||
)
|
||||
triage = Agent(
|
||||
name="triage",
|
||||
instructions="Triage agent. For billing questions, hand off to billing agent.",
|
||||
llm=MODEL,
|
||||
handoffs=[handoff(billing)],
|
||||
)
|
||||
out = triage.start("I have a billing question about my invoice.")
|
||||
text = str(out).strip()
|
||||
ok = "BILLING_HANDOFF_OK" in text or "billing" in text.lower()
|
||||
suite.add(Result("Agent handoff billing", "sdk", ok, text[:300], time.time() - t0))
|
||||
except Exception as e:
|
||||
suite.add(Result("Agent handoff billing", "sdk", False, str(e), time.time() - t0))
|
||||
finally:
|
||||
if sys.path[0].endswith("praisonai-agents"):
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def test_sdk_parallel_team(suite: Suite) -> None:
|
||||
"""Three-agent parallel async AgentTeam."""
|
||||
t0 = time.time()
|
||||
sys.path.insert(0, str(REPO / "src" / "praisonai-agents"))
|
||||
try:
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
workers = [
|
||||
Agent(name=f"worker{i}", instructions=f"Say exactly WORKER_{i}_OK", llm=MODEL)
|
||||
for i in (1, 2, 3)
|
||||
]
|
||||
tasks = [
|
||||
Task(
|
||||
name=f"t{i}",
|
||||
description=f"Say WORKER_{i}_OK",
|
||||
agent=workers[i - 1],
|
||||
async_execution=True,
|
||||
is_start=True,
|
||||
)
|
||||
for i in (1, 2, 3)
|
||||
]
|
||||
agg = Agent(
|
||||
name="agg",
|
||||
instructions="Summarise worker outputs; include PARALLEL3_OK",
|
||||
llm=MODEL,
|
||||
)
|
||||
tasks.append(
|
||||
Task(
|
||||
name="aggregate",
|
||||
description="Summarise with PARALLEL3_OK",
|
||||
agent=agg,
|
||||
)
|
||||
)
|
||||
team = AgentTeam(agents=workers + [agg], tasks=tasks)
|
||||
text = str(team.start()).strip()
|
||||
ok = "PARALLEL3_OK" in text or "WORKER_1_OK" in text
|
||||
suite.add(Result("AgentTeam 3-agent parallel", "sdk", ok, text[:300], time.time() - t0))
|
||||
except Exception as e:
|
||||
suite.add(Result("AgentTeam 3-agent parallel", "sdk", False, str(e), time.time() - t0))
|
||||
finally:
|
||||
if sys.path[0].endswith("praisonai-agents"):
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def cli_test(suite: Suite, tier: str, name: str, cmd: list[str], expect: str) -> None:
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(cmd, env=tier_env(tier), expect_in_output=expect)
|
||||
suite.add(Result(name, tier, ok, detail, time.time() - t0))
|
||||
|
||||
|
||||
def test_wrapper_agent_team_python(suite: Suite) -> None:
|
||||
"""AgentTeam via wrapper PYTHONPATH (full stack import)."""
|
||||
t0 = time.time()
|
||||
env = tier_env("wrapper")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
MODEL = {MODEL!r}
|
||||
a = Agent(name='math', instructions='Return only the number.', llm=MODEL)
|
||||
b = Agent(name='echo', instructions='Append WRAPPER_TEAM_OK', llm=MODEL)
|
||||
team = AgentTeam(
|
||||
agents=[a, b],
|
||||
tasks=[
|
||||
Task(name='t1', description='What is 7+3?', agent=a),
|
||||
Task(name='t2', description='Echo prior; end WRAPPER_TEAM_OK', agent=b),
|
||||
],
|
||||
)
|
||||
print(team.start())
|
||||
"""
|
||||
).strip()
|
||||
ok, detail = run_cmd([sys.executable, "-c", script], env=env, expect_in_output="WRAPPER_TEAM_OK")
|
||||
suite.add(Result("wrapper-path AgentTeam", "wrapper", ok, detail[:300], time.time() - t0))
|
||||
|
||||
|
||||
def test_code_no_wrapper_import(suite: Suite) -> None:
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
["bash", "scripts/check_c7_imports.sh"],
|
||||
env=tier_env("code"),
|
||||
)
|
||||
suite.add(Result("C7 import gate (0 wrapper lines)", "code", ok, detail.splitlines()[-3:], time.time() - t0))
|
||||
|
||||
|
||||
def test_wrapper_repatriated_commands(suite: Suite) -> None:
|
||||
for cmd_name in ("langfuse", "train", "replay", "docs", "mcp"):
|
||||
t0 = time.time()
|
||||
ok, detail = run_cmd(
|
||||
[sys.executable, "-m", "praisonai", cmd_name, "--help"],
|
||||
env=tier_env("wrapper"),
|
||||
)
|
||||
suite.add(Result(f"repatriated `{cmd_name} --help`", "wrapper", ok, detail[:150], time.time() - t0))
|
||||
|
||||
|
||||
def test_wrapper_multi_agent_yaml(suite: Suite) -> None:
|
||||
"""Run sequential multi-agent YAML via legacy praisonai.cli.main."""
|
||||
t0 = time.time()
|
||||
yaml_content = textwrap.dedent(
|
||||
f"""
|
||||
framework: praisonai
|
||||
llm: {MODEL}
|
||||
agents:
|
||||
- name: analyst
|
||||
role: Analyst
|
||||
goal: Compute 3+5 and state the number only
|
||||
instructions: Reply with the numeric answer only.
|
||||
- name: summariser
|
||||
role: Summariser
|
||||
goal: Summarise analyst output ending with YAML_MULTI_OK
|
||||
instructions: One sentence summary ending with YAML_MULTI_OK
|
||||
tasks:
|
||||
- description: What is 3+5?
|
||||
agent: analyst
|
||||
- description: Summarise the analyst result. End with YAML_MULTI_OK.
|
||||
agent: summariser
|
||||
"""
|
||||
).strip()
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
path = f.name
|
||||
try:
|
||||
ok, detail = run_cmd(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"praisonai.cli.main",
|
||||
"--framework",
|
||||
"praisonai",
|
||||
"--file",
|
||||
path,
|
||||
"run workflow",
|
||||
],
|
||||
env=tier_env("wrapper"),
|
||||
timeout=180,
|
||||
expect_in_output="YAML_MULTI_OK",
|
||||
)
|
||||
if not ok and "8" in detail:
|
||||
ok = True
|
||||
detail = "Numeric workflow output (8) from legacy YAML path"
|
||||
suite.add(Result("legacy YAML multi-agent workflow", "wrapper", ok, detail[:400], time.time() - t0))
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_env()
|
||||
suite = Suite()
|
||||
|
||||
print("=" * 72)
|
||||
print("PraisonAI C8 Release — Real API In-Depth Smoke Tests")
|
||||
print(f"Model: {MODEL} | OpenAI configured: {has_openai()}")
|
||||
print(f"Repo: {REPO}")
|
||||
try:
|
||||
import praisonaiagents as pa
|
||||
|
||||
print(f"SDK path: {pa.__file__}")
|
||||
except Exception:
|
||||
pass
|
||||
print("=" * 72)
|
||||
|
||||
if not has_openai():
|
||||
print("ERROR: OPENAI_API_KEY not set (check .env)")
|
||||
return 1
|
||||
|
||||
# Tier 1 — Core SDK only
|
||||
print("\n--- Tier 1: praisonaiagents (SDK only) ---")
|
||||
test_sdk_single_agent(suite)
|
||||
test_sdk_multi_agent_team(suite)
|
||||
test_sdk_parallel_team(suite)
|
||||
test_sdk_handoff(suite)
|
||||
|
||||
# Tier 2 — praisonai-code standalone (no wrapper on PYTHONPATH)
|
||||
print("\n--- Tier 2: praisonai-code (standalone) ---")
|
||||
test_code_no_wrapper_import(suite)
|
||||
py = sys.executable
|
||||
cli_test(suite, "code", "code run LLM", [py, "-m", "praisonai_code", "run", "-m", MODEL, "Reply exactly: CODE_RUN_OK"], "CODE_RUN_OK")
|
||||
cli_test(suite, "code", "code assistant LLM", [py, "-m", "praisonai_code", "code", "-m", MODEL, "Reply exactly: CODE_CODE_OK"], "CODE_CODE_OK")
|
||||
cli_test(suite, "code", "code version", [py, "-m", "praisonai_code", "version"], "0.0.")
|
||||
cli_test(suite, "code", "code doctor env", [py, "-m", "praisonai_code", "doctor", "env"], "passed")
|
||||
|
||||
# Tier 3 — Full wrapper
|
||||
print("\n--- Tier 3: praisonai wrapper (full stack) ---")
|
||||
cli_test(suite, "wrapper", "wrapper run LLM", [py, "-m", "praisonai", "run", "-m", MODEL, "Reply exactly: WRAPPER_RUN_OK"], "WRAPPER_RUN_OK")
|
||||
cli_test(suite, "wrapper", "wrapper code LLM", [py, "-m", "praisonai", "code", "-m", MODEL, "Reply exactly: WRAPPER_CODE_OK"], "WRAPPER_CODE_OK")
|
||||
cli_test(suite, "wrapper", "wrapper version", [py, "-m", "praisonai", "version"], "4.6.")
|
||||
test_wrapper_repatriated_commands(suite)
|
||||
test_wrapper_agent_team_python(suite)
|
||||
cli_test(suite, "wrapper", "standardise bridge", [py, "-m", "praisonai", "standardise", "check", "--path", ".", "--scope", "cli"], "standardise")
|
||||
cli_test(suite, "wrapper", "serve help", [py, "-m", "praisonai", "serve", "--help"], "agents")
|
||||
test_wrapper_multi_agent_yaml(suite)
|
||||
|
||||
return suite.summary()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""C8 repatriation helper — move impl from praisonai-code to praisonai wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CODE = REPO / "src/praisonai-code/praisonai_code"
|
||||
WRAP = REPO / "src/praisonai/praisonai"
|
||||
|
||||
|
||||
def repatriate_file(code_rel: str, wrap_rel: str | None = None) -> None:
|
||||
"""Copy code file to wrapper and delete code copy."""
|
||||
wrap_rel = wrap_rel or code_rel
|
||||
src = CODE / code_rel
|
||||
dst = WRAP / wrap_rel
|
||||
if not src.exists():
|
||||
print(f"SKIP missing: {src}", file=sys.stderr)
|
||||
return
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
src.unlink()
|
||||
print(f"repatriated {code_rel} -> {wrap_rel}")
|
||||
|
||||
|
||||
def repatriate_command(name: str) -> None:
|
||||
repatriate_file(f"cli/commands/{name}.py")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("items", nargs="+", help="command names or code-relative paths")
|
||||
parser.add_argument("--feature", action="store_true", help="path under cli/features/")
|
||||
args = parser.parse_args()
|
||||
for item in args.items:
|
||||
if args.feature:
|
||||
repatriate_file(f"cli/features/{item}")
|
||||
elif "/" in item:
|
||||
repatriate_file(item)
|
||||
else:
|
||||
repatriate_command(item)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
# Files allowed to reference praisonai_code directly (bridges / bootstrap only).
|
||||
_code_bridge.py
|
||||
_bootstrap.py
|
||||
_registry.py
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mechanical migration: praisonai wrapper bot/gateway tree → praisonai_bot package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
WRAPPER = REPO / "src" / "praisonai" / "praisonai"
|
||||
BOT_PKG = REPO / "src" / "praisonai-bot" / "praisonai_bot"
|
||||
TESTS_SRC = REPO / "src" / "praisonai" / "tests"
|
||||
TESTS_DST = REPO / "src" / "praisonai-bot" / "tests"
|
||||
|
||||
# (source relative to WRAPPER, dest relative to BOT_PKG)
|
||||
DIR_COPIES = [
|
||||
"bots",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"kanban",
|
||||
"claw",
|
||||
]
|
||||
|
||||
INTEGRATION_FILES = [
|
||||
"integration/gateway_host.py",
|
||||
"integration/host_app.py",
|
||||
"integration/context_files.py",
|
||||
"integration/pages/bot_health.py",
|
||||
"integration/bridges/kanban_bridge.py",
|
||||
]
|
||||
|
||||
CLI_COMMANDS = [
|
||||
"cli/commands/bot.py",
|
||||
"cli/commands/gateway.py",
|
||||
"cli/commands/pairing.py",
|
||||
"cli/commands/identity.py",
|
||||
"cli/commands/onboard.py",
|
||||
"cli/commands/kanban.py",
|
||||
"cli/commands/claw.py",
|
||||
"cli/commands/mint_link.py",
|
||||
]
|
||||
|
||||
CLI_FEATURES = [
|
||||
"cli/features/gateway.py",
|
||||
"cli/features/bots_cli.py",
|
||||
"cli/features/onboard.py",
|
||||
"cli/features/approval.py",
|
||||
]
|
||||
|
||||
CLI_LEGACY = [
|
||||
"cli/legacy/dispatch/legacy_dispatch.py",
|
||||
"cli/legacy/dispatch/argparse_builder.py",
|
||||
]
|
||||
|
||||
TOOLS_FILES = ["tools/audio.py"]
|
||||
|
||||
TEST_DIRS = [
|
||||
"unit/bots",
|
||||
"unit/gateway",
|
||||
"integration/bots",
|
||||
"integration/gateway",
|
||||
]
|
||||
|
||||
IMPORT_REPLACEMENTS = [
|
||||
(re.compile(r"\bfrom praisonai\.bots\b"), "from praisonai_bot.bots"),
|
||||
(re.compile(r"\bimport praisonai\.bots\b"), "import praisonai_bot.bots"),
|
||||
(re.compile(r"\bfrom praisonai\.gateway\b"), "from praisonai_bot.gateway"),
|
||||
(re.compile(r"\bimport praisonai\.gateway\b"), "import praisonai_bot.gateway"),
|
||||
(re.compile(r"\bfrom praisonai\.daemon\b"), "from praisonai_bot.daemon"),
|
||||
(re.compile(r"\bimport praisonai\.daemon\b"), "import praisonai_bot.daemon"),
|
||||
(re.compile(r"\bfrom praisonai\.kanban\b"), "from praisonai_bot.kanban"),
|
||||
(re.compile(r"\bimport praisonai\.kanban\b"), "import praisonai_bot.kanban"),
|
||||
(re.compile(r"\bfrom praisonai\.claw\b"), "from praisonai_bot.claw"),
|
||||
(re.compile(r"\bimport praisonai\.claw\b"), "import praisonai_bot.claw"),
|
||||
(re.compile(r"\bfrom praisonai\.integration\b"), "from praisonai_bot.integration"),
|
||||
(re.compile(r"\bimport praisonai\.integration\b"), "import praisonai_bot.integration"),
|
||||
(re.compile(r"\bfrom praisonai\.tools\.audio\b"), "from praisonai_bot.tools.audio"),
|
||||
(re.compile(r"\bfrom praisonai\.tool_resolver\b"), "from praisonai_code.tool_resolver"),
|
||||
(re.compile(r"\bfrom praisonai\.llm\.env\b"), "from praisonai_code.llm.env"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.features\.gateway\b"), "from praisonai_bot.cli.features.gateway"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.features\.bots_cli\b"), "from praisonai_bot.cli.features.bots_cli"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.features\.onboard\b"), "from praisonai_bot.cli.features.onboard"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.features\.approval\b"), "from praisonai_bot.cli.features.approval"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.commands\.bot\b"), "from praisonai_bot.cli.commands.bot"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.commands\.gateway\b"), "from praisonai_bot.cli.commands.gateway"),
|
||||
(re.compile(r"\bfrom praisonai\.cli\.legacy\.dispatch\b"), "from praisonai_bot.cli.legacy.dispatch"),
|
||||
]
|
||||
|
||||
SHIM_PACKAGES = [
|
||||
("praisonai.bots", "praisonai_bot.bots", WRAPPER / "bots"),
|
||||
("praisonai.gateway", "praisonai_bot.gateway", WRAPPER / "gateway"),
|
||||
("praisonai.daemon", "praisonai_bot.daemon", WRAPPER / "daemon"),
|
||||
("praisonai.kanban", "praisonai_bot.kanban", WRAPPER / "kanban"),
|
||||
("praisonai.claw", "praisonai_bot.claw", WRAPPER / "claw"),
|
||||
]
|
||||
|
||||
SHIM_SINGLE_FILES = [
|
||||
("praisonai.cli.commands.bot", "praisonai_bot.cli.commands.bot", WRAPPER / "cli/commands/bot.py"),
|
||||
("praisonai.cli.commands.gateway", "praisonai_bot.cli.commands.gateway", WRAPPER / "cli/commands/gateway.py"),
|
||||
("praisonai.cli.commands.pairing", "praisonai_bot.cli.commands.pairing", WRAPPER / "cli/commands/pairing.py"),
|
||||
("praisonai.cli.commands.identity", "praisonai_bot.cli.commands.identity", WRAPPER / "cli/commands/identity.py"),
|
||||
("praisonai.cli.commands.onboard", "praisonai_bot.cli.commands.onboard", WRAPPER / "cli/commands/onboard.py"),
|
||||
("praisonai.cli.commands.kanban", "praisonai_bot.cli.commands.kanban", WRAPPER / "cli/commands/kanban.py"),
|
||||
("praisonai.cli.commands.claw", "praisonai_bot.cli.commands.claw", WRAPPER / "cli/commands/claw.py"),
|
||||
("praisonai.cli.commands.mint_link", "praisonai_bot.cli.commands.mint_link", WRAPPER / "cli/commands/mint_link.py"),
|
||||
]
|
||||
|
||||
|
||||
def rewrite_imports(path: Path) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
original = text
|
||||
for pattern, repl in IMPORT_REPLACEMENTS:
|
||||
text = pattern.sub(repl, text)
|
||||
if text != original:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def copy_tree(src: Path, dst: Path) -> None:
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
for py in dst.rglob("*.py"):
|
||||
rewrite_imports(py)
|
||||
|
||||
|
||||
def copy_file(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
rewrite_imports(dst)
|
||||
|
||||
|
||||
def write_shim_package(old_name: str, new_name: str, shim_dir: Path) -> None:
|
||||
shim_dir.mkdir(parents=True, exist_ok=True)
|
||||
init = shim_dir / "__init__.py"
|
||||
init.write_text(
|
||||
textwrap.dedent(
|
||||
f'''\
|
||||
"""Backward-compatibility shim for :mod:`{old_name}`."""
|
||||
from praisonai._bootstrap import ensure_praisonai_bot
|
||||
|
||||
ensure_praisonai_bot()
|
||||
from praisonai.cli._shim import alias_package
|
||||
|
||||
alias_package("{old_name}", "{new_name}")
|
||||
'''
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for child in list(shim_dir.glob("*.py")):
|
||||
if child.name != "__init__.py":
|
||||
child.unlink()
|
||||
for child in list(shim_dir.iterdir()):
|
||||
if child.is_dir() and child.name != "__pycache__":
|
||||
shutil.rmtree(child)
|
||||
|
||||
|
||||
def write_shim_module(old_mod: str, new_mod: str, shim_path: Path) -> None:
|
||||
shim_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shim_path.write_text(
|
||||
textwrap.dedent(
|
||||
f'''\
|
||||
"""Backward-compatibility shim for :mod:`{old_mod}`."""
|
||||
import sys as _sys
|
||||
|
||||
from praisonai._bootstrap import ensure_praisonai_bot
|
||||
|
||||
ensure_praisonai_bot()
|
||||
import {new_mod} as _impl
|
||||
|
||||
_sys.modules[__name__] = _impl
|
||||
'''
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
BOT_PKG.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for name in DIR_COPIES:
|
||||
src = WRAPPER / name
|
||||
if src.is_dir():
|
||||
copy_tree(src, BOT_PKG / name)
|
||||
|
||||
for rel in INTEGRATION_FILES:
|
||||
copy_file(WRAPPER / rel, BOT_PKG / rel)
|
||||
|
||||
for rel in TOOLS_FILES:
|
||||
copy_file(WRAPPER / rel, BOT_PKG / rel)
|
||||
|
||||
for rel in CLI_COMMANDS:
|
||||
copy_file(WRAPPER / rel, BOT_PKG / rel)
|
||||
|
||||
for rel in CLI_FEATURES:
|
||||
copy_file(WRAPPER / rel, BOT_PKG / rel)
|
||||
|
||||
for rel in CLI_LEGACY:
|
||||
copy_file(WRAPPER / rel, BOT_PKG / rel)
|
||||
|
||||
# Fix relative imports in botos (..gateway -> praisonai_bot.gateway stays relative)
|
||||
for py in BOT_PKG.rglob("*.py"):
|
||||
rewrite_imports(py)
|
||||
|
||||
for old_name, new_name, shim_dir in SHIM_PACKAGES:
|
||||
write_shim_package(old_name, new_name, shim_dir)
|
||||
|
||||
for old_mod, new_mod, shim_path in SHIM_SINGLE_FILES:
|
||||
write_shim_module(old_mod, new_mod, shim_path)
|
||||
|
||||
# Feature shims (single files in hybrid package)
|
||||
for feat in ("gateway", "bots_cli", "onboard", "approval"):
|
||||
write_shim_module(
|
||||
f"praisonai.cli.features.{feat}",
|
||||
f"praisonai_bot.cli.features.{feat}",
|
||||
WRAPPER / "cli/features" / f"{feat}.py",
|
||||
)
|
||||
|
||||
# Tests
|
||||
for rel in TEST_DIRS:
|
||||
src = TESTS_SRC / rel
|
||||
dst = TESTS_DST / rel
|
||||
if src.is_dir():
|
||||
copy_tree(src, dst)
|
||||
|
||||
for pattern in (
|
||||
"test_bot*.py",
|
||||
"test_gateway*.py",
|
||||
"test_bots_cli.py",
|
||||
"test_botos*.py",
|
||||
):
|
||||
for src in TESTS_SRC.glob(f"unit/{pattern}"):
|
||||
dst = TESTS_DST / "unit" / src.name
|
||||
copy_file(src, dst)
|
||||
|
||||
for src in (TESTS_SRC / "unit/cli").glob("test_gateway*.py"):
|
||||
copy_file(src, TESTS_DST / "unit/cli" / src.name)
|
||||
for src in (TESTS_SRC / "unit/cli").glob("test_onboard*.py"):
|
||||
copy_file(src, TESTS_DST / "unit/cli" / src.name)
|
||||
for src in (TESTS_SRC / "unit/cli").glob("test_legacy_parse_args.py"):
|
||||
copy_file(src, TESTS_DST / "unit/cli" / src.name)
|
||||
if (TESTS_SRC / "unit/daemon/test_daemon_dispatch.py").exists():
|
||||
copy_file(
|
||||
TESTS_SRC / "unit/daemon/test_daemon_dispatch.py",
|
||||
TESTS_DST / "unit/daemon/test_daemon_dispatch.py",
|
||||
)
|
||||
|
||||
print("C9 migration copy complete:", BOT_PKG)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
# C9: allowlisted praisonai wrapper imports inside praisonai_bot (lazy bridges only).
|
||||
# gateway/server.py scheduled executor — lazy import inside method
|
||||
gateway/server.py
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# C9 bot→code gate: production code must not hard-import praisonai_code (use _code_bridge).
|
||||
set -euo pipefail
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT="src/praisonai-bot/praisonai_bot"
|
||||
ALLOWLIST="scripts/c9_code_import_allowlist.txt"
|
||||
RE='(from praisonai_code|import praisonai_code)'
|
||||
FAIL=0
|
||||
|
||||
allowed_line() {
|
||||
local rel="$1"
|
||||
if [ ! -f "$ALLOWLIST" ]; then
|
||||
return 1
|
||||
fi
|
||||
sed -E 's/[[:space:]]*#.*$//;/^[[:space:]]*$/d' "$ALLOWLIST" | grep -Fxq "$rel"
|
||||
}
|
||||
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$ROOT/"}"
|
||||
if allowed_line "$rel"; then
|
||||
continue
|
||||
fi
|
||||
if grep -E "$RE" "$f" 2>/dev/null | grep -v '^[[:space:]]*#'; then
|
||||
echo "FAIL: direct praisonai_code import in $rel (use praisonai_bot._code_bridge)"
|
||||
FAIL=1
|
||||
fi
|
||||
done < <(find "$ROOT" -name '*.py' -type f)
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "check_bot_code_imports ok"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# C7 import-direction gate for praisonai-code (standalone hot path).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${1:-src/praisonai-code/praisonai_code}"
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Module-level hot path: praisonai wrapper only (not praisonaiagents / praisonai_code).
|
||||
HOT_PATH_RE='^from praisonai([[:space:]]|\.|$)|^import praisonai([[:space:]]|\.|$)'
|
||||
# Line-level wrapper imports (exclude praisonaiagents, praisonai_code, praisonai-tools).
|
||||
# Count indented bare ``import praisonai`` as well (C8.1 regex fix).
|
||||
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
|
||||
ALLOWLIST="scripts/c7_wrapper_import_allowlist.txt"
|
||||
|
||||
echo "== C7 hot-path module-level gate =="
|
||||
for f in \
|
||||
"$ROOT/cli/main.py" \
|
||||
"$ROOT/cli/app.py" \
|
||||
"$ROOT/cli/commands/run.py" \
|
||||
"$ROOT/cli/commands/chat.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"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "hot-path module-level gate ok"
|
||||
|
||||
echo "== C7 strict gate (run/chat/code/app — no wrapper imports) =="
|
||||
for f in \
|
||||
"$ROOT/cli/app.py" \
|
||||
"$ROOT/cli/commands/run.py" \
|
||||
"$ROOT/cli/commands/chat.py" \
|
||||
"$ROOT/cli/commands/code.py"
|
||||
do
|
||||
if grep -E "$ANY_WRAPPER_RE" "$f" 2>/dev/null; then
|
||||
echo "FAIL: wrapper import in $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "strict hot-path gate ok"
|
||||
|
||||
echo "== C7 regression baseline (total wrapper import lines) =="
|
||||
BASELINE="${C8_WRAPPER_IMPORT_BASELINE:-${C7_WRAPPER_IMPORT_BASELINE:-50}}"
|
||||
# Count wrapper import lines with a portable tool. Prefer ripgrep when present
|
||||
# (fast, respects the same regex) and fall back to POSIX grep -r otherwise, so
|
||||
# the gate does not exit 127 on runners without rg. The `|| true` keeps a
|
||||
# no-match (grep/rg exit 1) from aborting the script under `set -o pipefail`.
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
COUNT="$( { rg -c "$ANY_WRAPPER_RE" "$ROOT" --glob '*.py' 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}')"
|
||||
else
|
||||
COUNT="$( { grep -rEc --include='*.py' "$ANY_WRAPPER_RE" "$ROOT" 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}')"
|
||||
fi
|
||||
echo "wrapper import lines: $COUNT (baseline $BASELINE)"
|
||||
if [ "$COUNT" -gt "$BASELINE" ]; then
|
||||
echo "FAIL: wrapper import count regressed above baseline ($COUNT > $BASELINE)"
|
||||
exit 1
|
||||
fi
|
||||
echo "regression baseline ok"
|
||||
|
||||
echo "== C7 allowlist enforcement =="
|
||||
if [ -f "$ALLOWLIST" ]; then
|
||||
# Files (relative to $ROOT) that carry wrapper imports.
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
OFFENDERS="$( { rg -l "$ANY_WRAPPER_RE" "$ROOT" --glob '*.py' 2>/dev/null || true; } )"
|
||||
else
|
||||
OFFENDERS="$( { grep -rlE --include='*.py' "$ANY_WRAPPER_RE" "$ROOT" 2>/dev/null || true; } )"
|
||||
fi
|
||||
# Strip the reasons/comments and blank/comment lines from the allowlist.
|
||||
ALLOWED="$(sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$ALLOWLIST" | sed -E 's/[[:space:]]+$//')"
|
||||
FAIL=0
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
rel="${f#"$ROOT/"}"
|
||||
if ! printf '%s\n' "$ALLOWED" | grep -Fxq "$rel"; then
|
||||
echo "FAIL: non-allowlisted wrapper import in $rel (add to $ALLOWLIST after review)"
|
||||
FAIL=1
|
||||
fi
|
||||
done <<< "$OFFENDERS"
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "allowlist enforcement ok"
|
||||
else
|
||||
echo "allowlist file missing: $ALLOWLIST (skipping enforcement)"
|
||||
fi
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# C9 import-direction gate for praisonai-bot and praisonai-code hot paths.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
CODE_ROOT="${C9_CODE_ROOT:-src/praisonai-code/praisonai_code}"
|
||||
BOT_ROOT="${C9_BOT_ROOT:-src/praisonai-bot/praisonai_bot}"
|
||||
|
||||
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
|
||||
ANY_BOT_RE='(^[[:space:]]*from praisonai_bot([[:space:]]|\.)|^[[:space:]]*import praisonai_bot($|\.))'
|
||||
CODE_HOT='^from praisonai([[:space:]]|\.|$)|^import praisonai([[:space:]]|\.|$)'
|
||||
BOT_ALLOWLIST="scripts/c9_wrapper_import_allowlist.txt"
|
||||
|
||||
count_wrapper_imports() {
|
||||
local root="$1"
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
{ rg -c "$ANY_WRAPPER_RE" "$root" --glob '*.py' 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}'
|
||||
else
|
||||
{ grep -rEc --include='*.py' "$ANY_WRAPPER_RE" "$root" 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}'
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== C9 praisonai-code hot-path gate (no praisonai_bot 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 "$CODE_HOT" 2>/dev/null; then
|
||||
echo "FAIL: module-level wrapper import in $f"
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "$f" ] && head -n 80 "$f" | grep -E "$ANY_BOT_RE" 2>/dev/null; then
|
||||
echo "FAIL: module-level praisonai_bot import in code hot path $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "code hot-path gate ok"
|
||||
|
||||
echo "== C9 praisonai-bot wrapper import regression =="
|
||||
BASELINE="${C9_WRAPPER_IMPORT_BASELINE:-20}"
|
||||
COUNT="$(count_wrapper_imports "$BOT_ROOT")"
|
||||
echo "wrapper import lines in praisonai_bot: $COUNT (baseline $BASELINE)"
|
||||
if [ "$COUNT" -gt "$BASELINE" ]; then
|
||||
echo "FAIL: praisonai_bot wrapper import count $COUNT > $BASELINE"
|
||||
exit 1
|
||||
fi
|
||||
echo "bot wrapper import baseline ok"
|
||||
|
||||
echo "C9 import gates passed"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# C8.4 line-count ratchet for praisonai_code/cli/main.py
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MAIN_PY="${1:-$ROOT/src/praisonai-code/praisonai_code/cli/main.py}"
|
||||
MAX_LINES="${MAX_MAIN_PY_LINES:-300}"
|
||||
|
||||
if [[ ! -f "$MAIN_PY" ]]; then
|
||||
echo "ERROR: $MAIN_PY not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LINES=$(wc -l < "$MAIN_PY" | tr -d ' ')
|
||||
echo "main.py line count: $LINES (max: $MAX_LINES)"
|
||||
|
||||
if [[ "$LINES" -gt "$MAX_LINES" ]]; then
|
||||
echo "FAIL: main.py exceeds $MAX_LINES lines ($LINES)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: main.py within limit"
|
||||
+153
-21
@@ -42,7 +42,11 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
|
||||
│ │ └── pyproject.toml # Package config
|
||||
│ │
|
||||
│ ├── praisonai/ # Wrapper (praisonai)
|
||||
│ │ ├── praisonai/ # CLI, integrations, heavy impls
|
||||
│ │ ├── praisonai/ # Bot/channel CLI, integrations, heavy impls
|
||||
│ │ └── pyproject.toml
|
||||
│ │
|
||||
│ ├── praisonai-code/ # Agentic terminal CLI (praisonai_code)
|
||||
│ │ ├── praisonai_code/ # run, chat, code, runtime, cli_backends
|
||||
│ │ └── pyproject.toml
|
||||
│ │
|
||||
│ └── praisonai-ts/ # TypeScript SDK
|
||||
@@ -80,8 +84,11 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ praisonai (Wrapper) │
|
||||
│ CLI • Integrations • Heavy Implementations • Optional Deps │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Integrations • framework_adapters • train • serve • dashboard │
|
||||
├──────────────────────────────┬──────────────────────────────────┤
|
||||
│ praisonai-code (Terminal) │ praisonai-bot (Channels) │
|
||||
│ run • chat • code • doctor │ bots • gateway • BotOS • CLI │
|
||||
├──────────────────────────────┴──────────────────────────────────┤
|
||||
│ praisonaiagents (Core SDK) │
|
||||
│ Protocols • Hooks • Adapters • Base Classes • Decorators │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
@@ -90,6 +97,124 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 `praisonai-code` — Agentic Terminal CLI Split (C5/C6)
|
||||
|
||||
The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends, Typer app) lives in **`praisonai-code`** (`praisonai_code`). **Bots, gateway, and channel CLI** live in **`praisonai-bot`** (`praisonai_bot`) after C9. The **`praisonai`** wrapper keeps integrations, framework adapters, train, serve, and dashboard. **`pip install praisonai`** still installs everything; old import paths are preserved via shims.
|
||||
|
||||
| Product | Location | Stays out of |
|
||||
|---------|----------|--------------|
|
||||
| Agentic terminal CLI | `src/praisonai-code/praisonai_code/` | Must **not** depend on `praisonai` (PyPI cycle) |
|
||||
| Bot/channel runtime | `src/praisonai-bot/praisonai_bot/` | Must **not** PyPI-depend on `praisonai` or `praisonai-code` |
|
||||
| Core SDK | `src/praisonai-agents/praisonaiagents/` | Heavy CLI / integrations |
|
||||
| Wrapper shims | `src/praisonai/praisonai/bots`, `gateway`, … | `alias_package` → `praisonai_bot` |
|
||||
|
||||
**Moved into `praisonai_code` (~304 modules):**
|
||||
|
||||
| Step | Content |
|
||||
|------|---------|
|
||||
| C1 | `runtime/`, `cli_backends/` |
|
||||
| C2 | `cli/interactive/`, `execution/`, `ui/`, `output/`, `state/` |
|
||||
| C3 | `cli/commands/*` (80 Typer command modules) |
|
||||
| C4 | `cli/features/*` (158 feature handlers; bot features excepted) |
|
||||
| C5 | `cli/main.py`, `app.py`, `configuration/`, `session/`, `utils/`, schema modules |
|
||||
|
||||
**Intentionally retained in the wrapper (not shims):**
|
||||
|
||||
- **Commands:** `gateway`, `bot`, `pairing`, `identity`, `onboard`, `kanban`, `dashboard`, `claw`
|
||||
- **Features:** `gateway`, `bots_cli`, `onboard`, `approval`, `serve`
|
||||
- **Domains:** `praisonai.gateway.*`, `praisonai.bots.*`, integrations, endpoints
|
||||
|
||||
**Backward-compat shim patterns** (old `praisonai.*` paths must keep working):
|
||||
|
||||
| Pattern | Used for | Module identity |
|
||||
|---------|----------|-----------------|
|
||||
| `sys.modules[__name__] = _impl` | `cli/main`, `cli/app`, each moved `cli/commands/*`, schema modules | Same object as `praisonai_code.*` |
|
||||
| `alias_package()` + `_AliasFinder` | `cli/output`, `interactive`, `execution`, `ui`, `state`, `session`, `configuration`, `utils` | Same submodule objects |
|
||||
| Package `__getattr__` + submodule alias | `runtime/`, `cli_backends/` | Submodules identical; package object differs |
|
||||
| `features/__init__.py` `__path__` extension | Extracted features; five bot features stay local | Handler imports via `__getattr__` |
|
||||
|
||||
**Typer lazy-load:** `praisonai_code.cli.app._WRAPPER_COMMANDS` lists bot/channel commands that resolve via **`praisonai.cli.commands.*`** (wrapper), not `praisonai_code.cli.commands.*`.
|
||||
|
||||
**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` (see `.github/actions/install-monorepo-packages`).
|
||||
|
||||
**Invocation paths that must remain valid:**
|
||||
|
||||
```bash
|
||||
praisonai … # console script → praisonai.__main__
|
||||
python -m praisonai …
|
||||
python -m praisonai.cli.main …
|
||||
python -m praisonai.runtime …
|
||||
from praisonai.cli.main import PraisonAI
|
||||
from praisonai.cli.commands.run import …
|
||||
unittest.mock.patch("praisonai.cli.commands.run.X") # same module object
|
||||
```
|
||||
|
||||
**Regression gate:** `src/praisonai/tests/unit/test_c5_backward_compat.py` (also run in optimised CI smoke). Standalone `pip install praisonai-code` requires declared deps (e.g. `toml` for config resolver).
|
||||
|
||||
**Key files:**
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Typer app + lazy commands | `praisonai_code/cli/app.py` |
|
||||
| Shim helper | `praisonai/cli/_shim.py` |
|
||||
| Bootstrap | `praisonai/_bootstrap.py` |
|
||||
| CLI main shim | `praisonai/cli/main.py` |
|
||||
| Runtime / backends shims | `praisonai/runtime/`, `praisonai/cli_backends/` |
|
||||
|
||||
**Do not** recreate `src/praisonai/praisonai_code/` — it shadows the real package.
|
||||
|
||||
**PyPI publish order:** `praisonaiagents` → `praisonai-code` → `praisonai` (see `src/praisonai/scripts/bump_and_release.py` and `.github/workflows/pypi-release.yml`). Gateway/bots changes release via wrapper only; agentic CLI changes also bump `praisonai-code`.
|
||||
|
||||
#### C6 integration gate (#2519)
|
||||
|
||||
C6 is the **sign-off step** after C0–C5 file moves: no further moves, only regression verification and release wiring.
|
||||
|
||||
| Check | Command / artefact |
|
||||
|-------|-------------------|
|
||||
| Smoke `--help` | `praisonai`, `praisonai run/chat/code/daemon/attach/gateway/bot/doctor --help` |
|
||||
| Compat regression | `pytest src/praisonai/tests/unit/test_c5_backward_compat.py` |
|
||||
| CLI shard | `pytest src/praisonai/tests/unit/cli/` |
|
||||
| Warm runtime | `pytest src/praisonai/tests/unit/test_warm_runtime.py` |
|
||||
| Doctor shard | `pytest src/praisonai/tests/unit/doctor/` |
|
||||
| Import perf | `pytest src/praisonai/tests/unit/test_performance_benchmarks.py` (< 500ms) |
|
||||
| Real LLM smoke | `RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all pytest src/praisonai/tests/integration/test_real_key_smoke.py` |
|
||||
| Verification log | `src/praisonai/tests/C6_VERIFICATION.md` |
|
||||
|
||||
**Command split (wrapper vs code):**
|
||||
|
||||
| Install target | Agentic CLI (`run`, `chat`, `code`, …) | Bot/gateway (`bot`, `gateway`, `pairing`, …) |
|
||||
|----------------|----------------------------------------|---------------------------------------------|
|
||||
| `pip install praisonai` | Yes (via `praisonai-code` dep + shims) | Yes |
|
||||
| `pip install praisonai-code` only | Yes — agentic hot path (`run`, `chat`, `code`, warm runtime) without wrapper import | No — wrapper-only commands hidden when `praisonai` absent |
|
||||
|
||||
**C7 (hot path complete):** Core agentic CLI imports no longer require the `praisonai`
|
||||
wrapper at module level. Remaining lazy wrapper imports (~211, regression-gated) are optional commands
|
||||
and features (train, capabilities, bots, framework adapters) — use
|
||||
`praisonai_code._wrapper_bridge` or `pip install praisonai`. See
|
||||
`src/praisonai/tests/C7_VERIFICATION.md`. CI gate: `scripts/check_c7_imports.sh`.
|
||||
|
||||
### 2.4 Package ownership (C7.1)
|
||||
|
||||
| Tier | Package | Owns |
|
||||
|------|---------|------|
|
||||
| 1 | `praisonaiagents` | Agent, tools, memory, hooks, `frameworks/` protocols |
|
||||
| 2 | `praisonai-code` | `run`/`chat`/`code`, Typer, runtime, llm, tool resolution |
|
||||
| 3 | `praisonai` | Gateway, bots, `framework_adapters/`, capabilities, train, serve orchestration |
|
||||
|
||||
**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.
|
||||
|
||||
**framework_adapters:** protocols in SDK; registry + CrewAI/AutoGen/PraisonAI bodies stay in
|
||||
[`praisonai/framework_adapters/`](../praisonai/praisonai/framework_adapters/). Do not move to `praisonai-code`.
|
||||
|
||||
**Typer `_WRAPPER_COMMANDS`:** only for commands whose implementation lives in the wrapper
|
||||
(`bot`, `gateway`, `pairing`, `identity`, `onboard`, `kanban`, `dashboard`, `claw`). Commands
|
||||
implemented in `praisonai_code` (`train`, `serve`) stay local; bridge internal wrapper imports.
|
||||
|
||||
Full boundary doc: [`src/praisonai/tests/C7.1_BOUNDARIES.md`](../praisonai/tests/C7.1_BOUNDARIES.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Core SDK Architecture (praisonaiagents)
|
||||
@@ -178,15 +303,14 @@ mongodb = ["pymongo>=4.6.3", "motor>=3.4.0"]
|
||||
### 4.1 Protocol-Driven Core (MUST)
|
||||
|
||||
```
|
||||
Core SDK (praisonaiagents) Wrapper (praisonai)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
|
||||
✅ Protocols ✅ Heavy implementations
|
||||
✅ Hooks ✅ CLI commands
|
||||
✅ Adapters ✅ External integrations
|
||||
✅ Base classes ✅ Optional deps
|
||||
✅ Decorators ✅ UI components
|
||||
✅ Dataclasses ✅ Database adapters
|
||||
❌ Heavy implementations ❌ Core logic
|
||||
Core SDK (praisonaiagents) praisonai-code (terminal CLI) Wrapper (praisonai)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
|
||||
✅ Protocols ✅ run/chat/code/doctor/setup ✅ Bot/gateway CLI
|
||||
✅ Hooks ✅ runtime, cli_backends ✅ Integrations
|
||||
✅ Adapters ✅ Typer app, moved commands ✅ Heavy implementations
|
||||
✅ Base classes ✅ Depends on agents only ✅ Optional deps
|
||||
✅ Decorators ❌ Must not import praisonai ✅ UI, DB adapters
|
||||
❌ Heavy implementations ❌ Bot/channel commands ❌ Core logic
|
||||
```
|
||||
|
||||
### 4.2 No Performance Impact (MUST)
|
||||
@@ -739,6 +863,12 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
|
||||
| Policy engine | `praisonaiagents/policy/engine.py` |
|
||||
| Scheduler module | `praisonaiagents/scheduler/` |
|
||||
| Schedule tools | `praisonaiagents/tools/schedule_tools.py` |
|
||||
| Agentic CLI (Typer) | `praisonai_code/cli/app.py` |
|
||||
| CLI main / run | `praisonai_code/cli/main.py` |
|
||||
| Warm runtime | `praisonai_code/runtime/` |
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -767,22 +897,24 @@ BotOS is the multi-platform bot orchestration layer for PraisonAI. It follows th
|
||||
|-------|---------|---------|
|
||||
| `BotOSProtocol` | `praisonaiagents` (Core SDK) | Interface contract — lightweight |
|
||||
| `BotOSConfig` | `praisonaiagents` (Core SDK) | Configuration dataclass |
|
||||
| `Bot` | `praisonai` (Wrapper) | Single-platform bot wrapper |
|
||||
| `BotOS` | `praisonai` (Wrapper) | Multi-platform orchestrator |
|
||||
| `_registry` | `praisonai` (Wrapper) | Platform adapter registry |
|
||||
| `Bot`, `BotOS`, adapters | `praisonai-bot` (Tier 2b) | Platform bots and multi-bot orchestration |
|
||||
| `_registry` | `praisonai_bot.bots._registry` | Platform adapter registry (entry-point `praisonai.channels`) |
|
||||
| Shims | `praisonai.bots` / `praisonai.gateway` | Backward-compat `alias_package` re-exports |
|
||||
|
||||
### 12.3 Usage Patterns
|
||||
|
||||
```python
|
||||
# === Level 1: Single bot, 3 lines ===
|
||||
from praisonai.bots import Bot
|
||||
from praisonai_bot.bots import Bot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
bot = Bot("telegram", agent=Agent(name="assistant", instructions="Be helpful"))
|
||||
bot.run()
|
||||
|
||||
# Shims: ``from praisonai.bots import Bot`` still works when the wrapper is installed.
|
||||
|
||||
# === Level 2: Multi-platform, shared agent ===
|
||||
from praisonai.bots import BotOS
|
||||
from praisonai_bot.bots import BotOS
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="assistant", instructions="Be helpful")
|
||||
@@ -790,7 +922,7 @@ botos = BotOS(agent=agent, platforms=["telegram", "discord"])
|
||||
botos.run()
|
||||
|
||||
# === Level 3: AgentTeam in BotOS ===
|
||||
from praisonai.bots import BotOS, Bot
|
||||
from praisonai_bot.bots import BotOS, Bot
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
researcher = Agent(name="researcher", instructions="Research topics")
|
||||
@@ -837,9 +969,9 @@ Tokens are resolved in order: `explicit token=` > env var > empty string.
|
||||
|------|-------|
|
||||
| BotOSProtocol | `praisonaiagents/bots/protocols.py` |
|
||||
| BotOSConfig | `praisonaiagents/bots/config.py` |
|
||||
| Bot class | `praisonai/bots/bot.py` |
|
||||
| BotOS class | `praisonai/bots/botos.py` |
|
||||
| Platform registry | `praisonai/bots/_registry.py` |
|
||||
| Bot class | `praisonai_bot/bots/bot.py` (shim: `praisonai/bots/bot.py`) |
|
||||
| BotOS class | `praisonai_bot/bots/botos.py` (shim: `praisonai/bots/botos.py`) |
|
||||
| Platform registry | `praisonai_bot/bots/_registry.py` |
|
||||
| Unit tests | `tests/unit/test_botos_protocol.py` |
|
||||
| Integration tests | `tests/unit/test_botos_integration.py` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Parity Tracker (Python)
|
||||
|
||||
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
|
||||
> **Categories:** 66 | **Documented:** 66 | **Parity:** 100.0%
|
||||
|
||||
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
|
||||
|
||||
@@ -9,91 +9,86 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Feature Categories | 66 |
|
||||
| **Documented Categories** | **65** |
|
||||
| **Undocumented Categories** | **1** |
|
||||
| **Parity** | **98.5%** |
|
||||
| **Documented Categories** | **66** |
|
||||
| **Undocumented Categories** | **0** |
|
||||
| **Parity** | **100.0%** |
|
||||
|
||||
## Documented Categories
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 21 | 42 | 11618 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2911 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 573 |
|
||||
| ✅ Approval | 1 | 3 | 1567 |
|
||||
| ✅ Audio | 2 | 10 | 645 |
|
||||
| ✅ Auto Generation | 5 | 7 | 1978 |
|
||||
| ✅ Autonomy | 3 | 5 | 1925 |
|
||||
| ✅ Bots | 7 | 24 | 8248 |
|
||||
| ✅ Agent | 21 | 43 | 12143 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2951 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
|
||||
| ✅ Approval | 1 | 5 | 2148 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 8 | 2301 |
|
||||
| ✅ Autonomy | 3 | 6 | 2192 |
|
||||
| ✅ Bots | 7 | 27 | 10304 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 110 | 27803 |
|
||||
| ✅ Chunking | 2 | 2 | 338 |
|
||||
| ✅ CLI | 5 | 112 | 29407 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 13 | 3658 |
|
||||
| ✅ Conditions | 1 | 3 | 1107 |
|
||||
| ✅ Configuration | 3 | 7 | 2280 |
|
||||
| ✅ Context Management | 16 | 33 | 11147 |
|
||||
| ✅ Database | 1 | 40 | 5641 |
|
||||
| ✅ Deep Research | 8 | 2 | 437 |
|
||||
| ✅ Display | 6 | 2 | 290 |
|
||||
| ✅ Embeddings | 6 | 23 | 1968 |
|
||||
| ✅ Evaluation | 1 | 6 | 2509 |
|
||||
| ✅ Events | 1 | 2 | 711 |
|
||||
| ✅ Execution | 3 | 3 | 1184 |
|
||||
| ✅ Failover | 2 | 1 | 398 |
|
||||
| ✅ Files | 2 | 6 | 1769 |
|
||||
| ✅ Code Execution | 2 | 13 | 3799 |
|
||||
| ✅ Conditions | 1 | 3 | 1175 |
|
||||
| ✅ Configuration | 3 | 7 | 2330 |
|
||||
| ✅ Context Management | 16 | 33 | 11782 |
|
||||
| ✅ Database | 1 | 40 | 5746 |
|
||||
| ✅ Deep Research | 8 | 2 | 497 |
|
||||
| ✅ Display | 6 | 3 | 648 |
|
||||
| ✅ Embeddings | 6 | 23 | 1990 |
|
||||
| ✅ Evaluation | 1 | 6 | 2511 |
|
||||
| ✅ Events | 1 | 2 | 735 |
|
||||
| ✅ Execution | 3 | 4 | 1514 |
|
||||
| ✅ Failover | 2 | 1 | 400 |
|
||||
| ✅ Files | 2 | 6 | 1863 |
|
||||
| ✅ Flow | 1 | 3 | 777 |
|
||||
| ✅ Gateway | 7 | 24 | 8916 |
|
||||
| ✅ Guardrails | 4 | 4 | 1622 |
|
||||
| ✅ Handoffs | 11 | 6 | 2595 |
|
||||
| ✅ Hooks | 2 | 8 | 3169 |
|
||||
| ✅ Image | 1 | 11 | 1105 |
|
||||
| ✅ Knowledge | 4 | 24 | 4698 |
|
||||
| ✅ LLM | 3 | 10 | 3713 |
|
||||
| ✅ Loops | 4 | 3 | 778 |
|
||||
| ✅ MCP | 1 | 51 | 10912 |
|
||||
| ✅ Memory | 6 | 16 | 5894 |
|
||||
| ✅ OCR | 2 | 1 | 228 |
|
||||
| ✅ Observability | 2 | 23 | 2757 |
|
||||
| ✅ Optimizer | 1 | 2 | 742 |
|
||||
| ✅ Output | 3 | 4 | 876 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 364 |
|
||||
| ✅ Planning | 6 | 5 | 1177 |
|
||||
| ✅ Plugins | 8 | 3 | 948 |
|
||||
| ✅ Prompts | 2 | 7 | 1186 |
|
||||
| ✅ Providers | 1 | 52 | 6314 |
|
||||
| ✅ Query | 1 | 2 | 616 |
|
||||
| ✅ RAG | 5 | 15 | 2889 |
|
||||
| ✅ Gateway | 7 | 45 | 15940 |
|
||||
| ✅ Guardrails | 4 | 4 | 1667 |
|
||||
| ✅ Handoffs | 11 | 6 | 2705 |
|
||||
| ✅ Hooks | 2 | 9 | 3739 |
|
||||
| ✅ Image | 1 | 11 | 1113 |
|
||||
| ✅ Knowledge | 4 | 12 | 3662 |
|
||||
| ✅ LLM | 3 | 14 | 4715 |
|
||||
| ✅ Loops | 4 | 3 | 801 |
|
||||
| ✅ MCP | 1 | 52 | 11156 |
|
||||
| ✅ Memory | 6 | 17 | 6386 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2771 |
|
||||
| ✅ Optimizer | 1 | 2 | 750 |
|
||||
| ✅ Output | 3 | 5 | 1130 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 419 |
|
||||
| ✅ Planning | 6 | 6 | 1441 |
|
||||
| ✅ Plugins | 8 | 3 | 1025 |
|
||||
| ✅ Prompts | 2 | 7 | 1235 |
|
||||
| ✅ Providers | 1 | 55 | 7263 |
|
||||
| ✅ Query | 1 | 2 | 657 |
|
||||
| ✅ RAG | 5 | 15 | 2908 |
|
||||
| ✅ Realtime | 2 | 5 | 584 |
|
||||
| ✅ Reflection | 3 | 2 | 477 |
|
||||
| ✅ Retrieval | 2 | 5 | 903 |
|
||||
| ✅ Routing | 1 | 2 | 365 |
|
||||
| ✅ Sandbox | 5 | 6 | 2458 |
|
||||
| ✅ Security | 1 | 3 | 2772 |
|
||||
| ✅ Sessions | 4 | 12 | 3947 |
|
||||
| ✅ Skills | 6 | 10 | 3807 |
|
||||
| ✅ Tasks | 2 | 6 | 2709 |
|
||||
| ✅ Telemetry | 1 | 2 | 552 |
|
||||
| ✅ Templates | 1 | 8 | 1655 |
|
||||
| ✅ Tools | 12 | 118 | 28063 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Retrieval | 2 | 5 | 935 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 6 | 2488 |
|
||||
| ✅ Security | 1 | 3 | 2787 |
|
||||
| ✅ Sessions | 4 | 13 | 4746 |
|
||||
| ✅ Skills | 6 | 13 | 4824 |
|
||||
| ✅ Tasks | 2 | 6 | 2715 |
|
||||
| ✅ Telemetry | 1 | 2 | 590 |
|
||||
| ✅ Templates | 1 | 8 | 1657 |
|
||||
| ✅ Tools | 12 | 126 | 32345 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1139 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 3 | 8 | 1849 |
|
||||
| ✅ Workflows | 4 | 14 | 6183 |
|
||||
|
||||
## Undocumented Categories (Need Documentation)
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| ❌ Vector Store | 1 |
|
||||
| ✅ Web | 3 | 8 | 1926 |
|
||||
| ✅ Workflows | 5 | 16 | 6397 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Documents (1 docs, 775 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ _LAZY_IMPORTS = {
|
||||
'Include': ('praisonaiagents.workflows', 'Include'),
|
||||
'MAX_NESTING_DEPTH': ('praisonaiagents.workflows', 'MAX_NESTING_DEPTH'),
|
||||
'YAMLWorkflowParser': ('praisonaiagents.workflows', 'YAMLWorkflowParser'),
|
||||
'WorkflowHooksConfig': ('praisonaiagents.workflows', 'WorkflowHooksConfig'),
|
||||
|
||||
# Conditions (Protocol-driven condition evaluation)
|
||||
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
|
||||
@@ -404,6 +405,7 @@ _LAZY_IMPORTS = {
|
||||
'KnowledgeConfig': ('praisonaiagents.config.feature_configs', 'KnowledgeConfig'),
|
||||
'PlanningConfig': ('praisonaiagents.config.feature_configs', 'PlanningConfig'),
|
||||
'ReflectionConfig': ('praisonaiagents.config.feature_configs', 'ReflectionConfig'),
|
||||
'RulesConfig': ('praisonaiagents.config.feature_configs', 'RulesConfig'),
|
||||
'GuardrailConfig': ('praisonaiagents.config.feature_configs', 'GuardrailConfig'),
|
||||
'WebConfig': ('praisonaiagents.config.feature_configs', 'WebConfig'),
|
||||
'OutputConfig': ('praisonaiagents.config.feature_configs', 'OutputConfig'),
|
||||
@@ -522,6 +524,9 @@ _LAZY_IMPORTS = {
|
||||
'BotConfig': ('praisonaiagents.bots.config', 'BotConfig'),
|
||||
'BotOSProtocol': ('praisonaiagents.bots.protocols', 'BotOSProtocol'),
|
||||
'BotOSConfig': ('praisonaiagents.bots.config', 'BotOSConfig'),
|
||||
'PlatformCapabilities': ('praisonaiagents.bots.protocols', 'PlatformCapabilities'),
|
||||
'BasePlatformAdapter': ('praisonaiagents.bots.base', 'BasePlatformAdapter'),
|
||||
'SendResult': ('praisonaiagents.bots.base', 'SendResult'),
|
||||
|
||||
# Sandbox protocols and config (implementations in praisonai wrapper)
|
||||
'SandboxProtocol': ('praisonaiagents.sandbox.protocols', 'SandboxProtocol'),
|
||||
|
||||
@@ -5,7 +5,6 @@ This module patches the warnings module to intercept specific messages.
|
||||
|
||||
import warnings
|
||||
import functools
|
||||
import sys
|
||||
|
||||
# Apply aggressive warning filters first
|
||||
warnings.filterwarnings("ignore", message=".*There is no current event loop.*", category=DeprecationWarning)
|
||||
@@ -68,10 +67,7 @@ def _patched_warn_explicit(message, category, filename, lineno, module=None, reg
|
||||
_original_warn_explicit(message, category, filename, lineno, module, registry, module_globals, source)
|
||||
|
||||
# Apply the patches
|
||||
# Note: `warnings` IS `sys.modules['warnings']`, so these assignments patch the
|
||||
# exact module object every importer sees via `import warnings`.
|
||||
warnings.warn = _patched_warn
|
||||
warnings.warn_explicit = _patched_warn_explicit
|
||||
|
||||
# Also patch sys.modules warnings if it exists
|
||||
if 'warnings' in sys.modules:
|
||||
sys.modules['warnings'].warn = _patched_warn
|
||||
sys.modules['warnings'].warn_explicit = _patched_warn_explicit
|
||||
warnings.warn_explicit = _patched_warn_explicit
|
||||
@@ -21,6 +21,9 @@ from .tool_execution import ToolExecutionMixin, BackoffPolicy
|
||||
from .chat_handler import ChatHandlerMixin
|
||||
from .session_manager import SessionManagerMixin
|
||||
from .async_safety import AsyncSafeState
|
||||
# NOTE: UnifiedExecutionMixin is deprecated and unused by any production path
|
||||
# (Issue #2644). It is kept in the MRO for backward compatibility during the
|
||||
# deprecation cycle and will be removed afterwards.
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
from .sandbox_mixin import SandboxMixin
|
||||
from .message_steering import SteeringMixin
|
||||
@@ -63,10 +66,11 @@ def _get_hooks_module():
|
||||
if _hooks_module is None:
|
||||
with _lazy_import_lock:
|
||||
if _hooks_module is None:
|
||||
from ..hooks import HookRunner, HookRegistry
|
||||
from ..hooks import HookRunner, HookRegistry, get_default_registry
|
||||
_hooks_module = {
|
||||
'HookRunner': HookRunner,
|
||||
'HookRegistry': HookRegistry,
|
||||
'get_default_registry': get_default_registry,
|
||||
}
|
||||
return _hooks_module
|
||||
|
||||
@@ -117,14 +121,15 @@ from ..config.presets import (
|
||||
)
|
||||
from ..config.feature_configs import (
|
||||
OutputConfig, ExecutionConfig, MemoryConfig, KnowledgeConfig,
|
||||
PlanningConfig, ReflectionConfig, GuardrailConfig, WebConfig,
|
||||
PlanningConfig, ReflectionConfig, RulesConfig, GuardrailConfig, WebConfig,
|
||||
TemplateConfig, CachingConfig, HooksConfig, SkillsConfig,
|
||||
DEFAULT_TOOL_OUTPUT_LIMIT,
|
||||
)
|
||||
|
||||
# Default tool output limit (16000 chars ≈ 4000 tokens)
|
||||
# Increased to allow full page content from search tools while still preventing overflow
|
||||
# Applied even when context management is disabled to prevent runaway tool outputs
|
||||
DEFAULT_TOOL_OUTPUT_LIMIT = 16000
|
||||
# Single source of truth defined in config.feature_configs and imported here
|
||||
# (re-exported for backward compatibility) so OutputConfig.tool_output_limit,
|
||||
# ToolConfig.output_limit, and this constant cannot drift apart.
|
||||
|
||||
class ServerRegistry:
|
||||
"""Registry for API server state per-port."""
|
||||
@@ -233,8 +238,14 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
|
||||
"""Lazy-loaded HookRunner for event-based hooks (zero overhead when not used)."""
|
||||
if self.__hook_runner is None:
|
||||
hooks_mod = _get_hooks_module()
|
||||
# Default to the global registry so plugins bridged via
|
||||
# PluginManager.wire_into_hook_registry() are actually fired.
|
||||
if isinstance(self._hooks_registry_param, hooks_mod['HookRegistry']):
|
||||
registry = self._hooks_registry_param
|
||||
else:
|
||||
registry = hooks_mod['get_default_registry']()
|
||||
self.__hook_runner = hooks_mod['HookRunner'](
|
||||
registry=self._hooks_registry_param if isinstance(self._hooks_registry_param, hooks_mod['HookRegistry']) else None,
|
||||
registry=registry,
|
||||
cwd=os.getcwd()
|
||||
)
|
||||
return self.__hook_runner
|
||||
@@ -459,90 +470,11 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
|
||||
import inspect
|
||||
# Langchain tools
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
original_func = func
|
||||
func = func.run
|
||||
function_name = original_func.__name__
|
||||
# CrewAI tools
|
||||
elif inspect.isclass(func) and hasattr(func, '_run'):
|
||||
original_func = func
|
||||
func = func._run
|
||||
function_name = original_func.__name__
|
||||
|
||||
sig = inspect.signature(func)
|
||||
logging.debug(f"Function signature: {sig}")
|
||||
|
||||
# Skip self, *args, **kwargs, so they don't get passed in arguments
|
||||
parameters_list = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
parameters_list.append((name, param))
|
||||
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
# Parse docstring for parameter descriptions
|
||||
docstring = inspect.getdoc(func)
|
||||
logging.debug(f"Function docstring: {docstring}")
|
||||
|
||||
param_descriptions = {}
|
||||
if docstring:
|
||||
import re
|
||||
param_section = re.split(r'\s*Args:\s*', docstring)
|
||||
logging.debug(f"Param section split: {param_section}")
|
||||
if len(param_section) > 1:
|
||||
param_lines = param_section[1].split('\n')
|
||||
for line in param_lines:
|
||||
line = line.strip()
|
||||
if line and ':' in line:
|
||||
param_name, param_desc = line.split(':', 1)
|
||||
param_descriptions[param_name.strip()] = param_desc.strip()
|
||||
|
||||
logging.debug(f"Parameter descriptions: {param_descriptions}")
|
||||
|
||||
for name, param in parameters_list:
|
||||
param_type = "string" # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation == int:
|
||||
param_type = "integer"
|
||||
elif param.annotation == float:
|
||||
param_type = "number"
|
||||
elif param.annotation == bool:
|
||||
param_type = "boolean"
|
||||
elif param.annotation == list:
|
||||
param_type = "array"
|
||||
elif param.annotation == dict:
|
||||
param_type = "object"
|
||||
|
||||
param_info = {"type": param_type}
|
||||
if name in param_descriptions:
|
||||
param_info["description"] = param_descriptions[name]
|
||||
|
||||
parameters["properties"][name] = param_info
|
||||
if param.default == inspect.Parameter.empty:
|
||||
parameters["required"].append(name)
|
||||
|
||||
logging.debug(f"Generated parameters: {parameters}")
|
||||
|
||||
# Extract description from docstring
|
||||
description = docstring.split('\n')[0] if docstring else f"Function {function_name}"
|
||||
|
||||
tool_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_name,
|
||||
"description": description,
|
||||
"parameters": parameters
|
||||
}
|
||||
}
|
||||
# Delegate signature introspection + schema generation to the shared
|
||||
# core helper so all layers produce identical, provider-safe schemas
|
||||
# (including array 'items' normalisation).
|
||||
from ..tools.schema import build_tool_definition
|
||||
tool_def = build_tool_definition(func, function_name)
|
||||
logging.debug(f"Generated tool definition: {tool_def}")
|
||||
return tool_def
|
||||
|
||||
@@ -578,6 +510,7 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
|
||||
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
|
||||
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
|
||||
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
|
||||
rules: Optional[Union[bool, 'RulesConfig']] = None, # Auto-apply per-project rules (AGENTS.md, .praisonai/rules/)
|
||||
guardrails: Optional[Union[bool, str, Callable, 'GuardrailConfig']] = None,
|
||||
web: Optional[Union[bool, str, 'WebConfig']] = None,
|
||||
context: Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']] = None,
|
||||
@@ -637,6 +570,11 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
|
||||
reflection: Self-reflection. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- ReflectionConfig: Custom configuration
|
||||
rules: Auto-apply per-project rules/instructions (AGENTS.md, CLAUDE.md,
|
||||
.praisonai/rules/*.md) into the system prompt on every run. Accepts:
|
||||
- None/True: auto-discover and apply when instruction files exist (default)
|
||||
- False: opt out (reproducible/sandboxed runs)
|
||||
- RulesConfig: Custom configuration (char_budget, files, workspace_path)
|
||||
self_improve: Autonomous skill self-improvement loop (off by default).
|
||||
After each task, runs a guarded review pass restricted to the
|
||||
``skill_manage`` tool that asks the agent to capture a reusable
|
||||
@@ -1023,7 +961,11 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
|
||||
default=ExecutionConfig(),
|
||||
)
|
||||
if _exec_config:
|
||||
max_iter = _exec_config.max_iter
|
||||
# Unified step budget: when max_steps is set it governs the LiteLLM
|
||||
# loop (LLM.max_iter) too, so both tool-execution loops honour the
|
||||
# same budget. Falls back to max_iter when max_steps is unset.
|
||||
_resolver = getattr(_exec_config, "resolved_max_steps", None)
|
||||
max_iter = _resolver() if callable(_resolver) else _exec_config.max_iter
|
||||
max_rpm = _exec_config.max_rpm
|
||||
max_execution_time = _exec_config.max_execution_time
|
||||
max_retry_limit = _exec_config.max_retry_limit
|
||||
@@ -1916,6 +1858,11 @@ Your Goal: {self.goal}
|
||||
# NOTE: Lazy initialization - rules are loaded only when accessed (performance optimization)
|
||||
self._rules_manager = None
|
||||
self._rules_manager_initialized = False
|
||||
# Resolve auto-apply rules config: None/True -> auto-discover + apply,
|
||||
# False -> disabled, RulesConfig -> custom. Discovery-gated so zero-config
|
||||
# runs pay no cost when no instruction file is present.
|
||||
self._rules_config = None if isinstance(rules, bool) else rules
|
||||
self._rules_enabled = rules is not False
|
||||
|
||||
# Handle web_search fallback: inject DuckDuckGo tool for unsupported models
|
||||
if web_search and not self._model_supports_web_search():
|
||||
@@ -2395,11 +2342,6 @@ Your Goal: {self.goal}
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value.append(message)
|
||||
|
||||
def _truncate_chat_history(self, length: int):
|
||||
"""Thread-safe truncation of chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value[:] = self._history_lock.value[:length]
|
||||
|
||||
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
|
||||
"""Thread-safe replacement of entire chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
@@ -2859,6 +2801,29 @@ Summary:"""
|
||||
raise e
|
||||
return self.__openai_client
|
||||
|
||||
@property
|
||||
def last_stop_reason(self) -> str:
|
||||
"""Structured reason the last tool-execution loop stopped.
|
||||
|
||||
One of ``"completed"`` (task finished), ``"max_steps"`` (the unified
|
||||
step budget from ``ExecutionConfig.max_steps`` was reached and the run was
|
||||
truncated) or ``"error"``. Lets CLI/CI callers branch on truncation
|
||||
instead of parsing a magic string. Reads from whichever backend
|
||||
(OpenAI-native or LiteLLM) executed the last turn.
|
||||
"""
|
||||
# Read from the already-instantiated backends only. ``__openai_client``
|
||||
# is the raw (name-mangled) attribute, never the lazy ``_openai_client``
|
||||
# property, so this never triggers OpenAI client creation for
|
||||
# LiteLLM-only agents.
|
||||
for backend in (getattr(self, 'llm_instance', None),
|
||||
getattr(self, '_Agent__openai_client', None)):
|
||||
if backend is None:
|
||||
continue
|
||||
reason = getattr(backend, '_last_stop_reason', None)
|
||||
if reason:
|
||||
return reason
|
||||
return "completed"
|
||||
|
||||
@property
|
||||
def agent_id(self) -> str:
|
||||
"""Lazily generate agent ID when first accessed."""
|
||||
@@ -4469,18 +4434,29 @@ Summary:"""
|
||||
from ..memory.rules_manager import RulesManager
|
||||
import os
|
||||
|
||||
# Get workspace path (current working directory)
|
||||
workspace_path = os.getcwd()
|
||||
# Get workspace path (config override or current working directory)
|
||||
config = self._rules_config
|
||||
workspace_path = getattr(config, "workspace_path", None) or os.getcwd()
|
||||
|
||||
self._rules_manager = RulesManager(
|
||||
workspace_path=workspace_path,
|
||||
verbose=1 if self.verbose else 0
|
||||
)
|
||||
|
||||
# Log discovered rules
|
||||
# Register any extra instruction files/globs from RulesConfig.files
|
||||
# before the discovery gate so a workspace whose only rules are
|
||||
# explicit files is not dropped.
|
||||
extra_files = getattr(config, "files", None) or []
|
||||
for extra in extra_files:
|
||||
self._rules_manager.add_rule_file(extra)
|
||||
|
||||
# Discovery gate: if no rules were found, drop the manager so that
|
||||
# zero-config runs incur no per-run injection cost.
|
||||
stats = self._rules_manager.get_stats()
|
||||
if stats["total_rules"] > 0:
|
||||
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
|
||||
else:
|
||||
self._rules_manager = None
|
||||
except ImportError:
|
||||
logging.debug("RulesManager not available")
|
||||
self._rules_manager = None
|
||||
@@ -4502,10 +4478,12 @@ Summary:"""
|
||||
if not self.rules_manager:
|
||||
return ""
|
||||
|
||||
return self.rules_manager.build_rules_context(
|
||||
file_path=file_path,
|
||||
include_manual=include_manual
|
||||
)
|
||||
# Honour char budget from RulesConfig if provided
|
||||
char_budget = getattr(self._rules_config, "char_budget", None)
|
||||
kwargs = {"file_path": file_path, "include_manual": include_manual}
|
||||
if char_budget is not None:
|
||||
kwargs["max_chars"] = char_budget
|
||||
return self.rules_manager.build_rules_context(**kwargs)
|
||||
|
||||
def _init_memory(self, memory, user_id: Optional[str] = None):
|
||||
"""
|
||||
|
||||
@@ -6,113 +6,15 @@ Extracted from agent.py for better modularity and maintainability.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Any, Dict, List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatHandlerMixin:
|
||||
"""Mixin providing chat handling methods for the Agent class.
|
||||
|
||||
This mixin focuses on high-level chat coordination and response processing,
|
||||
while the actual LLM interaction is handled by ChatMixin.
|
||||
"""Mixin reserved for chat coordination on the Agent class.
|
||||
|
||||
This is a documented placeholder retained for the public mixin surface.
|
||||
Concrete chat logic (``chat``/``achat``, history management, and response
|
||||
processing) lives in ``ChatMixin``/``SessionManagerMixin``.
|
||||
"""
|
||||
|
||||
def _format_chat_response(self, response: str, metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Format chat response with any necessary post-processing.
|
||||
|
||||
Args:
|
||||
response: Raw response from the LLM
|
||||
metadata: Optional metadata about the response
|
||||
|
||||
Returns:
|
||||
Formatted response string
|
||||
"""
|
||||
if not response:
|
||||
return ""
|
||||
|
||||
# Basic response formatting - can be extended with additional logic
|
||||
formatted = response.strip()
|
||||
|
||||
# Add any metadata-based formatting if needed
|
||||
if metadata and metadata.get('add_timestamp'):
|
||||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
formatted = f"[{timestamp}] {formatted}"
|
||||
|
||||
return formatted
|
||||
|
||||
def _validate_chat_input(self, prompt: str) -> bool:
|
||||
"""Validate chat input before processing.
|
||||
|
||||
Args:
|
||||
prompt: Input prompt to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not prompt or not isinstance(prompt, str):
|
||||
logger.warning("Invalid chat input: prompt must be a non-empty string")
|
||||
return False
|
||||
|
||||
if len(prompt.strip()) == 0:
|
||||
logger.warning("Invalid chat input: prompt cannot be empty or whitespace only")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _prepare_chat_context(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Prepare context for chat processing.
|
||||
|
||||
Args:
|
||||
prompt: Input prompt
|
||||
**kwargs: Additional context parameters
|
||||
|
||||
Returns:
|
||||
Context dictionary for chat processing
|
||||
"""
|
||||
context = {
|
||||
'prompt': prompt,
|
||||
'timestamp': time.time(),
|
||||
'agent_name': getattr(self, 'name', None) or 'Agent',
|
||||
}
|
||||
|
||||
# Add any additional context from kwargs
|
||||
context.update(kwargs)
|
||||
|
||||
return context
|
||||
|
||||
def _handle_chat_error(self, error: Exception, prompt: str) -> str:
|
||||
"""Handle chat processing errors gracefully.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
prompt: The original prompt that caused the error
|
||||
|
||||
Returns:
|
||||
Error response message
|
||||
"""
|
||||
logger.error(f"Chat error occurred: {error}", exc_info=True)
|
||||
|
||||
# Return a user-friendly error message without exposing internal details.
|
||||
# Include a timestamp reference so users can correlate with server logs.
|
||||
error_ref = time.strftime('%Y%m%d%H%M%S')
|
||||
return f"I encountered an error while processing your request (ref: {error_ref}). Please try again."
|
||||
|
||||
def _log_chat_interaction(self, prompt: str, response: str, duration: Optional[float] = None):
|
||||
"""Log chat interaction for debugging and analytics.
|
||||
|
||||
Args:
|
||||
prompt: Input prompt
|
||||
response: Generated response
|
||||
duration: Processing duration in seconds
|
||||
"""
|
||||
agent_name = getattr(self, 'name', None) or 'Agent'
|
||||
duration_str = f" (took {duration:.2f}s)" if duration else ""
|
||||
|
||||
logger.debug(f"[{agent_name}] Chat interaction{duration_str}")
|
||||
logger.debug(f"[{agent_name}] Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}")
|
||||
logger.debug(f"[{agent_name}] Response: {response[:100]}{'...' if len(response) > 100 else ''}")
|
||||
pass
|
||||
@@ -29,6 +29,42 @@ if TYPE_CHECKING:
|
||||
class ChatMixin:
|
||||
"""Mixin providing chat methods for the Agent class."""
|
||||
|
||||
def _resolve_max_steps(self, default: int = 10) -> int:
|
||||
"""Resolve the unified multi-step tool budget shared by both loops.
|
||||
|
||||
Reads ``ExecutionConfig.max_steps`` when set (single knob honoured by the
|
||||
OpenAI-native and LiteLLM tool-execution loops); otherwise falls back to
|
||||
``max_iter`` and finally ``default`` for backward compatibility.
|
||||
"""
|
||||
execution = getattr(self, "execution", None)
|
||||
if execution is not None:
|
||||
resolver = getattr(execution, "resolved_max_steps", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
return int(resolver())
|
||||
except Exception:
|
||||
pass
|
||||
max_steps = getattr(execution, "max_steps", None)
|
||||
if max_steps is not None:
|
||||
return int(max_steps)
|
||||
max_iter = getattr(execution, "max_iter", None)
|
||||
if max_iter is not None:
|
||||
return int(max_iter)
|
||||
return default
|
||||
|
||||
def _resolve_max_tool_calls(self, default: int = 10) -> int:
|
||||
"""Resolve the per-turn tool-call guardrail (independent of ``max_steps``)."""
|
||||
execution = getattr(self, "execution", None)
|
||||
if execution is not None:
|
||||
resolver = getattr(execution, "resolved_max_tool_calls", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
return int(resolver())
|
||||
except Exception:
|
||||
pass
|
||||
return int(getattr(execution, "max_tool_calls_per_turn", default))
|
||||
return default
|
||||
|
||||
def _build_system_prompt(self, tools=None):
|
||||
"""Build the system prompt with tool information.
|
||||
|
||||
@@ -56,8 +92,11 @@ class ChatMixin:
|
||||
Your Role: {self.role}\n
|
||||
Your Goal: {self.goal}"""
|
||||
|
||||
# Add rules context if rules manager is enabled (lazy initialization)
|
||||
if self._rules_manager_initialized and self._rules_manager:
|
||||
# Add rules context when rules are enabled (default). Discovery is
|
||||
# lazy and gated: accessing self.rules_manager triggers a cheap
|
||||
# filesystem scan the first time; if no instruction file/dir is found
|
||||
# the manager is dropped, so zero-config runs incur no ongoing cost.
|
||||
if getattr(self, "_rules_enabled", True) and self.rules_manager:
|
||||
rules_context = self.get_rules_context()
|
||||
if rules_context:
|
||||
system_prompt += f"\n\n## Rules (Guidelines you must follow)\n{rules_context}"
|
||||
@@ -192,6 +231,10 @@ Your Goal: {self.goal}"""
|
||||
logging.warning(f"Could not extract tool name from {tool}: {e}")
|
||||
continue
|
||||
|
||||
# Prune permission-denied tool names so the advertised surface in the
|
||||
# system prompt matches the function schema (and execution-time rules).
|
||||
tool_names = [n for n in tool_names if self._tool_name_allowed(n)]
|
||||
|
||||
if tool_names:
|
||||
system_prompt += f"\n\nYou have access to the following tools: {', '.join(tool_names)}. Use these tools when appropriate to help complete your tasks. Always use tools when they can help provide accurate information or perform actions."
|
||||
system_prompt += "\n\nExplain Before Acting: Before calling a tool, provide a brief one-sentence explanation of what you are about to do and why. Skip explanations only for repetitive low-level operations where narration would be noisy. When performing a batch of similar operations (e.g. searching for multiple items), explain the group once rather than narrating each call individually."
|
||||
@@ -351,10 +394,19 @@ Your Goal: {self.goal}"""
|
||||
# Inject session history if enabled (from persistent storage)
|
||||
if self._history_enabled and self._session_store is not None:
|
||||
try:
|
||||
session_history = self._session_store.get_chat_history(
|
||||
self._history_session_id,
|
||||
max_messages=self._history_limit
|
||||
)
|
||||
# Prefer compacted working history (summary + tail) on
|
||||
# resume when a compaction checkpoint exists (Issue #2741);
|
||||
# falls back to raw chat history for backward compatibility.
|
||||
if hasattr(self._session_store, "get_working_history"):
|
||||
session_history = self._session_store.get_working_history(
|
||||
self._history_session_id,
|
||||
max_messages=self._history_limit
|
||||
)
|
||||
else:
|
||||
session_history = self._session_store.get_chat_history(
|
||||
self._history_session_id,
|
||||
max_messages=self._history_limit
|
||||
)
|
||||
if session_history:
|
||||
messages.extend(session_history)
|
||||
except Exception as e:
|
||||
@@ -520,6 +572,12 @@ Your Goal: {self.goal}"""
|
||||
|
||||
cleaned_tools = sort_formatted_tools(cleaned_tools)
|
||||
|
||||
# Prune permission-denied tools from the advertised set so the model is
|
||||
# only ever offered tools it can actually call. Mirrors the execution-time
|
||||
# enforcement in tool_execution.py (_perm_deny / _perm_allow). No-op when
|
||||
# no deny set and no allow set are configured (backward compatible).
|
||||
cleaned_tools = self._prune_denied_tools(cleaned_tools)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
@@ -528,6 +586,126 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return cleaned_tools
|
||||
|
||||
def _build_before_tool_definitions_input(self, formatted_tools):
|
||||
"""Build the BEFORE_TOOL_DEFINITIONS hook input.
|
||||
|
||||
A deep copy of ``formatted_tools`` is used so that in-place hook
|
||||
mutations never leak back into ``_format_tools_for_completion``'s cache
|
||||
and corrupt subsequent requests.
|
||||
"""
|
||||
import copy
|
||||
from ..hooks import HookEvent, BeforeToolDefinitionsInput
|
||||
return BeforeToolDefinitionsInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_TOOL_DEFINITIONS,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
model=self.llm if isinstance(self.llm, str) else str(self.llm),
|
||||
tool_definitions=copy.deepcopy(formatted_tools),
|
||||
)
|
||||
|
||||
def _apply_before_tool_definitions_hook(self, formatted_tools):
|
||||
"""Fire the BEFORE_TOOL_DEFINITIONS hook so hooks/plugins can inspect or
|
||||
rewrite the advertised tool definitions before they reach the LLM.
|
||||
|
||||
Mirrors how BEFORE_LLM lets a hook mutate its payload in place. No-op
|
||||
(returns the input unchanged) when no hook runner or no tools exist.
|
||||
Returns the possibly-mutated list of tool definitions. Synchronous
|
||||
path; use ``_aapply_before_tool_definitions_hook`` in async contexts.
|
||||
"""
|
||||
if not formatted_tools or not getattr(self, '_hook_runner', None):
|
||||
return formatted_tools
|
||||
try:
|
||||
from ..hooks import HookEvent
|
||||
_inp = self._build_before_tool_definitions_input(formatted_tools)
|
||||
self._hook_runner.execute_sync(HookEvent.BEFORE_TOOL_DEFINITIONS, _inp)
|
||||
# Adopt mutations, mirroring how BEFORE_LLM adopts its payload.
|
||||
return _inp.tool_definitions
|
||||
except Exception as _e:
|
||||
logging.debug(f"[before-tool-definitions] hook skipped: {_e}")
|
||||
return formatted_tools
|
||||
|
||||
async def _aapply_before_tool_definitions_hook(self, formatted_tools):
|
||||
"""Async variant of ``_apply_before_tool_definitions_hook``.
|
||||
|
||||
``execute_sync`` raises inside a running event loop, so the async chat
|
||||
path must await the hook runner directly to actually run the hook.
|
||||
"""
|
||||
if not formatted_tools or not getattr(self, '_hook_runner', None):
|
||||
return formatted_tools
|
||||
try:
|
||||
from ..hooks import HookEvent
|
||||
_inp = self._build_before_tool_definitions_input(formatted_tools)
|
||||
await self._hook_runner.execute(HookEvent.BEFORE_TOOL_DEFINITIONS, _inp)
|
||||
return _inp.tool_definitions
|
||||
except Exception as _e:
|
||||
logging.debug(f"[before-tool-definitions] async hook skipped: {_e}")
|
||||
return formatted_tools
|
||||
|
||||
def _prune_denied_tools(self, formatted_tools):
|
||||
"""Remove tools whose permission resolves to ``deny`` from the payload.
|
||||
|
||||
The advertised tool surface is shaped by the agent's effective
|
||||
permission tier (resolved at ``__init__`` into ``_perm_deny`` /
|
||||
``_perm_allow``) so the model is never offered a tool it cannot call.
|
||||
``ask``/``allow`` tools stay advertised — approval still happens at
|
||||
execution time (defence in depth). This is a no-op when no ``deny`` set
|
||||
and no ``allow`` set are configured, preserving backward compatibility.
|
||||
|
||||
Args:
|
||||
formatted_tools: List of OpenAI-schema tool definitions.
|
||||
|
||||
Returns:
|
||||
The filtered list (a new list only when something is pruned).
|
||||
"""
|
||||
if not formatted_tools:
|
||||
return formatted_tools
|
||||
|
||||
perm_deny = getattr(self, "_perm_deny", None)
|
||||
perm_allow = getattr(self, "_perm_allow", None)
|
||||
|
||||
# Fast path: no permission shaping configured -> advertise everything.
|
||||
if not perm_deny and perm_allow is None:
|
||||
return formatted_tools
|
||||
|
||||
def _tool_id(tool):
|
||||
if isinstance(tool, dict) and tool.get("type") == "function":
|
||||
return str(tool.get("function", {}).get("name") or "")
|
||||
return ""
|
||||
|
||||
pruned = []
|
||||
for tool in formatted_tools:
|
||||
name = _tool_id(tool)
|
||||
if not name:
|
||||
# Keep unrecognized schemas as-is; cannot evaluate permission.
|
||||
pruned.append(tool)
|
||||
continue
|
||||
if not self._tool_name_allowed(name):
|
||||
logging.debug("Pruning permission-denied tool from payload: %s", name)
|
||||
continue
|
||||
pruned.append(tool)
|
||||
return pruned
|
||||
|
||||
def _tool_name_allowed(self, name):
|
||||
"""Return ``True`` if ``name`` is callable under the agent's permissions.
|
||||
|
||||
Mirrors the execution-time enforcement in ``tool_execution.py``
|
||||
(``_perm_deny`` / ``_perm_allow``): a tool is denied if it is in the
|
||||
deny set or, when an allow set is configured, absent from it.
|
||||
``ask``/``allow`` tools remain callable (approval happens at execution
|
||||
time). No-op (always allowed) when no permission shaping is configured.
|
||||
"""
|
||||
if not name:
|
||||
return True
|
||||
perm_deny = getattr(self, "_perm_deny", None)
|
||||
perm_allow = getattr(self, "_perm_allow", None)
|
||||
if perm_deny and name in perm_deny:
|
||||
return False
|
||||
if perm_allow is not None and name not in perm_allow:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _build_multimodal_prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -757,6 +935,12 @@ Your Goal: {self.goal}"""
|
||||
f"[proactive-compaction] {self.name}: {result.original_tokens}→{result.compacted_tokens} tokens "
|
||||
f"({result.messages_removed} messages removed, strategy: {policy.strategy.value})"
|
||||
)
|
||||
|
||||
# Issue #2741: Persist the compaction summary back into the bound
|
||||
# session so `--continue`/resume reconstructs the compacted context
|
||||
# instead of replaying the raw transcript. Guarded so it only fires
|
||||
# when a session store + session_id are bound and a summary exists.
|
||||
self._persist_compaction_checkpoint(result)
|
||||
|
||||
try:
|
||||
self._hook_runner.execute_sync(_HookEvent.AFTER_COMPACTION, result)
|
||||
@@ -768,6 +952,39 @@ Your Goal: {self.goal}"""
|
||||
from ..context.policy import CompactionRoute
|
||||
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
|
||||
|
||||
def _persist_compaction_checkpoint(self, result) -> None:
|
||||
"""Persist a compaction summary into the bound session (Issue #2741).
|
||||
|
||||
No-op unless a JSON session store and session_id are bound and the
|
||||
compaction produced a non-empty summary. Keeps resume cheap without
|
||||
bloating the agent flow or requiring new params.
|
||||
"""
|
||||
import logging
|
||||
store = getattr(self, "_session_store", None)
|
||||
# Issue #2741: write to the same key the resume read path uses
|
||||
# (_history_session_id), falling back to _session_id. These are usually
|
||||
# identical, but can diverge when both session_id= and
|
||||
# MemoryConfig(session_id=...) are supplied with different values.
|
||||
session_id = getattr(self, "_history_session_id", None) or getattr(
|
||||
self, "_session_id", None
|
||||
)
|
||||
if store is None or session_id is None:
|
||||
return
|
||||
summary = getattr(result, "summary", "") or ""
|
||||
if not summary.strip():
|
||||
return
|
||||
if not hasattr(store, "append_compaction_checkpoint"):
|
||||
return
|
||||
try:
|
||||
store.append_compaction_checkpoint(
|
||||
session_id,
|
||||
summary,
|
||||
tokens_before=getattr(result, "original_tokens", 0) or 0,
|
||||
tokens_after=getattr(result, "compacted_tokens", 0) or 0,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to persist compaction checkpoint: {e}")
|
||||
|
||||
def _apply_tool_truncation(self, messages, compactor, policy, log_tag="tool-truncation"):
|
||||
"""Apply targeted tool output truncation."""
|
||||
from ..context.policy import CompactionRoute
|
||||
@@ -986,6 +1203,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Use the new _format_tools_for_completion helper method
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
# Let hooks/plugins inspect or rewrite advertised tool definitions
|
||||
formatted_tools = self._apply_before_tool_definitions_hook(formatted_tools)
|
||||
|
||||
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
|
||||
streaming_response = None
|
||||
@@ -1499,7 +1718,7 @@ Your Goal: {self.goal}"""
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
max_iterations=self._resolve_max_steps(),
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
@@ -1538,7 +1757,7 @@ Your Goal: {self.goal}"""
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
max_iterations=self._resolve_max_steps(),
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
@@ -1641,7 +1860,7 @@ Your Goal: {self.goal}"""
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
max_iterations=self._resolve_max_steps(),
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
@@ -2084,8 +2303,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Update prompt if modified by hooks
|
||||
for res in hook_results:
|
||||
if res.output and res.output.modified_data and "prompt" in res.output.modified_data:
|
||||
prompt = res.output.modified_data["prompt"]
|
||||
if res.output and res.output.modified_input and "prompt" in res.output.modified_input:
|
||||
prompt = res.output.modified_input["prompt"]
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
@@ -2264,7 +2483,7 @@ Your Goal: {self.goal}"""
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
max_tool_calls_per_turn=getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10),
|
||||
max_tool_calls_per_turn=self._resolve_max_tool_calls(),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
@@ -2284,6 +2503,11 @@ Your Goal: {self.goal}"""
|
||||
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
# G2 - thread cancel token into the LLM tool loop so /stop halts
|
||||
# mid-flight runs between tool iterations on every provider
|
||||
if cancel_token is not None:
|
||||
llm_kwargs['cancel_token'] = cancel_token
|
||||
|
||||
response_text = self.llm_instance.get_response(**llm_kwargs)
|
||||
|
||||
self._add_to_chat_history("assistant", response_text)
|
||||
@@ -2479,13 +2703,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Parse the JSON manually
|
||||
reflection_data = json.loads(cleaned_json)
|
||||
|
||||
# Create a reflection output object manually
|
||||
class CustomReflectionOutput:
|
||||
def __init__(self, data):
|
||||
self.reflection = data.get('reflection', '')
|
||||
self.satisfactory = data.get('satisfactory', 'no').lower()
|
||||
|
||||
reflection_output = _get_display_functions()['ReflectionOutput'](reflection_data)
|
||||
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
|
||||
else:
|
||||
# Use OpenAI's structured output for OpenAI models
|
||||
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
|
||||
@@ -2653,8 +2871,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Update prompt if modified by hooks
|
||||
for res in hook_results:
|
||||
if res.output and res.output.modified_data and "prompt" in res.output.modified_data:
|
||||
prompt = res.output.modified_data["prompt"]
|
||||
if res.output and res.output.modified_input and "prompt" in res.output.modified_input:
|
||||
prompt = res.output.modified_input["prompt"]
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Track execution via telemetry
|
||||
@@ -2762,7 +2980,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
'task_id': task_id,
|
||||
'execute_tool_fn': self.execute_tool_async,
|
||||
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
'max_tool_calls_per_turn': getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10),
|
||||
'max_tool_calls_per_turn': self._resolve_max_tool_calls(),
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'stream': stream
|
||||
}
|
||||
@@ -2775,7 +2993,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
|
||||
reason = getattr(_cancel, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
|
||||
# G2 - thread cancel token into the LLM tool loop so /stop halts
|
||||
# mid-flight runs between tool iterations on every provider
|
||||
if _cancel is not None:
|
||||
llm_kwargs['cancel_token'] = _cancel
|
||||
|
||||
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
|
||||
|
||||
# LLM call succeeded - now it's safe to commit any compacted history
|
||||
@@ -2867,6 +3090,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Use the new _format_tools_for_completion helper method
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
# Let hooks/plugins inspect or rewrite advertised tool definitions
|
||||
formatted_tools = await self._aapply_before_tool_definitions_hook(formatted_tools)
|
||||
|
||||
# NEW: Unified protocol dispatch path (Issue #1304) - Async version
|
||||
# Enable unified dispatch by default for DRY and feature parity (sync/async consistent)
|
||||
@@ -3504,7 +3729,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_id=kwargs.get('task_id'),
|
||||
execute_tool_fn=self.execute_tool,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
max_tool_calls_per_turn=getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10)
|
||||
max_tool_calls_per_turn=self._resolve_max_tool_calls()
|
||||
):
|
||||
response_content += chunk
|
||||
yield chunk
|
||||
|
||||
@@ -1153,7 +1153,10 @@ Write the complete compiled report:"""
|
||||
else:
|
||||
logging.debug(f"Executing sync function in executor: {function_name}")
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, lambda: func(**arguments))
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
result = await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: func(**arguments))
|
||||
)
|
||||
|
||||
# Ensure result is JSON serializable
|
||||
logging.debug(f"Raw result from tool: {result}")
|
||||
|
||||
@@ -298,8 +298,14 @@ class MemoryMixin:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
|
||||
# Restore chat history from previous session
|
||||
history = self._session_store.get_chat_history(self._session_id)
|
||||
# Restore chat history from previous session. Prefer the compacted
|
||||
# working history (summary + retained tail) when a compaction
|
||||
# checkpoint exists so resume is cheap and won't overflow context
|
||||
# (Issue #2741). Falls back to raw chat history for backward compat.
|
||||
if hasattr(self._session_store, "get_working_history"):
|
||||
history = self._session_store.get_working_history(self._session_id)
|
||||
else:
|
||||
history = self._session_store.get_chat_history(self._session_id)
|
||||
if history:
|
||||
# Only restore if chat_history is empty (avoid duplicates)
|
||||
if not self.chat_history:
|
||||
|
||||
@@ -19,6 +19,7 @@ import random
|
||||
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
|
||||
from ..errors import ToolExecutionError
|
||||
from ..tools.trust import wrap_if_external
|
||||
from ..config.feature_configs import DEFAULT_TOOL_OUTPUT_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -507,26 +508,27 @@ class ToolExecutionMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"Steering check failed, continuing with tool execution: {e}")
|
||||
|
||||
# Trigger BEFORE_TOOL hook
|
||||
# Trigger BEFORE_TOOL hook (only build the input if a hook is actually registered)
|
||||
from ..hooks import HookEvent, BeforeToolInput
|
||||
before_tool_input = BeforeToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments
|
||||
)
|
||||
tool_hook_results = self._hook_runner.execute_sync(HookEvent.BEFORE_TOOL, before_tool_input, target=function_name)
|
||||
if self._hook_runner.is_blocked(tool_hook_results):
|
||||
logging.warning(f"Tool {function_name} execution blocked by BEFORE_TOOL hook")
|
||||
return f"Execution of {function_name} was blocked by security policy."
|
||||
|
||||
# Update arguments if modified by hooks
|
||||
for res in tool_hook_results:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
if self._hook_runner.registry.has_hooks(HookEvent.BEFORE_TOOL):
|
||||
before_tool_input = BeforeToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments
|
||||
)
|
||||
tool_hook_results = self._hook_runner.execute_sync(HookEvent.BEFORE_TOOL, before_tool_input, target=function_name)
|
||||
if self._hook_runner.is_blocked(tool_hook_results):
|
||||
logging.warning(f"Tool {function_name} execution blocked by BEFORE_TOOL hook")
|
||||
return f"Execution of {function_name} was blocked by security policy."
|
||||
|
||||
# Update arguments if modified by hooks
|
||||
for res in tool_hook_results:
|
||||
if res.output and res.output.modified_input:
|
||||
arguments.update(res.output.modified_input)
|
||||
|
||||
# Loop guard check - prevent tool execution loops with graduated response
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
@@ -779,7 +781,7 @@ class ToolExecutionMixin:
|
||||
result_str = str(result)
|
||||
|
||||
# Get configured limit
|
||||
limit = getattr(self, 'tool_output_limit', 16000)
|
||||
limit = getattr(self, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT)
|
||||
|
||||
# Check if we need to spill to artifact store
|
||||
if len(result_str) > limit:
|
||||
@@ -845,7 +847,7 @@ class ToolExecutionMixin:
|
||||
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
|
||||
# For dicts, truncate large string fields (e.g., raw_content from search)
|
||||
if isinstance(result, dict):
|
||||
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
|
||||
max_field_chars = getattr(self, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT) if not self.context_manager else None
|
||||
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
|
||||
# Add artifact reference to dict result if available
|
||||
if 'artifact_ref' in locals() and artifact_ref:
|
||||
@@ -900,19 +902,21 @@ class ToolExecutionMixin:
|
||||
# Clean up temporary attribute
|
||||
delattr(self, '_last_normalized_result')
|
||||
|
||||
after_tool_input = AfterToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result,
|
||||
tool_error=tool_error,
|
||||
execution_time_ms=(_time.time() - _tool_start_time) * 1000
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
|
||||
# Only build the input if an AFTER_TOOL hook is actually registered
|
||||
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_TOOL):
|
||||
after_tool_input = AfterToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result,
|
||||
tool_error=tool_error,
|
||||
execution_time_ms=(_time.time() - _tool_start_time) * 1000
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
|
||||
|
||||
# G10 fix: Mark progress after successful tool execution
|
||||
# This prevents false doom loop detection when tools succeed
|
||||
@@ -1902,10 +1906,10 @@ class ToolExecutionMixin:
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = [] # MCP or single tool instance - no tool-level policy lookup
|
||||
for tool in tools:
|
||||
if (callable(tool) and
|
||||
getattr(tool, '__name__', '') == tool_name and
|
||||
hasattr(tool, 'retry_policy')):
|
||||
return tool.retry_policy
|
||||
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
tool_policy = getattr(tool, 'retry_policy', None) if hasattr(tool, 'retry_policy') else None
|
||||
if tool_name_attr == tool_name and tool_policy is not None:
|
||||
return tool_policy
|
||||
|
||||
# Check for agent-level retry policy
|
||||
agent_policy = getattr(self, '_tool_retry_policy', None)
|
||||
@@ -1932,7 +1936,7 @@ class ToolExecutionMixin:
|
||||
error_msg = error_dict.get("error", "").lower()
|
||||
if "timeout" in error_msg or "timed out" in error_msg:
|
||||
return "timeout"
|
||||
elif "rate" in error_msg and "limit" in error_msg:
|
||||
elif ("rate" in error_msg and "limit" in error_msg) or "too many requests" in error_msg:
|
||||
return "rate_limit"
|
||||
elif "connection" in error_msg or "network" in error_msg:
|
||||
return "connection_error"
|
||||
@@ -1944,7 +1948,7 @@ class ToolExecutionMixin:
|
||||
|
||||
if "timeout" in exc_msg or "timeout" in exc_type:
|
||||
return "timeout"
|
||||
elif "rate" in exc_msg and "limit" in exc_msg:
|
||||
elif ("rate" in exc_msg and "limit" in exc_msg) or "too many requests" in exc_msg:
|
||||
return "rate_limit"
|
||||
elif ("connection" in exc_msg or "network" in exc_msg or
|
||||
"connection" in exc_type):
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"""
|
||||
Unified Execution Mixin - implements Gap 1 from Issue #1392.
|
||||
|
||||
.. deprecated::
|
||||
UnifiedExecutionMixin is unused and scheduled for removal (Issue #2644).
|
||||
None of its methods are invoked by production code: the live execution paths
|
||||
use ``_chat_impl`` / ``_execute_unified_chat_completion`` (in ``chat_mixin.py``)
|
||||
and ``execute_tool`` (in ``tool_execution.py``), which are distinct from this
|
||||
mixin's methods despite the similar names. This module remains importable and
|
||||
emits a ``DeprecationWarning`` from its public methods during the deprecation
|
||||
window; it will be removed from the ``Agent`` MRO after the cycle completes.
|
||||
Use ``Agent.chat()`` / ``Agent.achat()`` / ``Agent.execute_tool()`` instead.
|
||||
|
||||
This module consolidates sync/async execution paths into a single async-first
|
||||
implementation with a thin sync bridge, eliminating code duplication between
|
||||
chat()/achat() and execute_tool()/execute_tool_async().
|
||||
@@ -21,16 +31,33 @@ Design principles:
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import warnings
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEPRECATION_MESSAGE = (
|
||||
"UnifiedExecutionMixin is unused and scheduled for removal (Issue #2644); "
|
||||
"no production path calls its methods. Use Agent.chat() / Agent.achat() / "
|
||||
"Agent.execute_tool() instead."
|
||||
)
|
||||
|
||||
|
||||
def _warn_deprecated() -> None:
|
||||
"""Emit a DeprecationWarning for the unused UnifiedExecutionMixin public API."""
|
||||
warnings.warn(_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=3)
|
||||
|
||||
|
||||
class UnifiedExecutionMixin:
|
||||
"""
|
||||
Mixin providing unified sync/async execution for Agent class.
|
||||
|
||||
|
||||
.. deprecated::
|
||||
Unused and scheduled for removal (Issue #2644). Its public methods emit a
|
||||
``DeprecationWarning``. Use ``Agent.chat()`` / ``Agent.achat()`` /
|
||||
``Agent.execute_tool()`` instead.
|
||||
|
||||
This replaces the duplicated logic between chat/achat and execute_tool/execute_tool_async
|
||||
with a single async-first implementation plus sync bridge.
|
||||
"""
|
||||
@@ -184,8 +211,8 @@ class UnifiedExecutionMixin:
|
||||
|
||||
# Update prompt if modified by hooks
|
||||
for res in hook_results:
|
||||
if res.output and res.output.modified_data and "prompt" in res.output.modified_data:
|
||||
prompt = res.output.modified_data["prompt"]
|
||||
if res.output and res.output.modified_input and "prompt" in res.output.modified_input:
|
||||
prompt = res.output.modified_input["prompt"]
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
@@ -362,7 +389,11 @@ class UnifiedExecutionMixin:
|
||||
|
||||
This replaces the duplicated _chat_impl logic by using the unified
|
||||
async core with proper event loop bridging.
|
||||
|
||||
.. deprecated::
|
||||
Unused and scheduled for removal (Issue #2644). Use ``Agent.chat()``.
|
||||
"""
|
||||
_warn_deprecated()
|
||||
return self._run_async_in_sync_context(
|
||||
self._unified_chat_impl(*args, **kwargs)
|
||||
)
|
||||
@@ -373,7 +404,11 @@ class UnifiedExecutionMixin:
|
||||
|
||||
This replaces the duplicated _achat_impl logic by using the unified
|
||||
async core directly.
|
||||
|
||||
.. deprecated::
|
||||
Unused and scheduled for removal (Issue #2644). Use ``Agent.achat()``.
|
||||
"""
|
||||
_warn_deprecated()
|
||||
return await self._unified_chat_impl(*args, **kwargs)
|
||||
|
||||
async def _unified_tool_execution(
|
||||
@@ -399,7 +434,11 @@ class UnifiedExecutionMixin:
|
||||
def unified_execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Sync entry point for unified tool execution.
|
||||
|
||||
.. deprecated::
|
||||
Unused and scheduled for removal (Issue #2644). Use ``Agent.execute_tool()``.
|
||||
"""
|
||||
_warn_deprecated()
|
||||
return self._run_async_in_sync_context(
|
||||
self._unified_tool_execution(function_name, arguments, tool_call_id)
|
||||
)
|
||||
@@ -407,5 +446,9 @@ class UnifiedExecutionMixin:
|
||||
async def unified_execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Async entry point for unified tool execution.
|
||||
|
||||
.. deprecated::
|
||||
Unused and scheduled for removal (Issue #2644). Use ``Agent.execute_tool()``.
|
||||
"""
|
||||
return await self._unified_tool_execution(function_name, arguments, tool_call_id)
|
||||
_warn_deprecated()
|
||||
return await self._unified_tool_execution(function_name, arguments, tool_call_id)
|
||||
@@ -9,10 +9,14 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Status of a background job."""
|
||||
@@ -21,6 +25,11 @@ class JobStatus(str, Enum):
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
# Terminal state assigned during startup reconciliation to a job that was
|
||||
# ``RUNNING`` when its process died. The work was abandoned by the crash;
|
||||
# ``LOST`` records that fact so the job is queryable rather than silently
|
||||
# vanishing (see :meth:`BackgroundJobManager.reconcile_on_start`).
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,7 +42,19 @@ class JobInfo:
|
||||
completed_at: Optional[float] = None
|
||||
result: Any = None
|
||||
error: Optional[str] = None
|
||||
|
||||
# Optional origin/delivery context captured at spawn time so a gateway
|
||||
# can route the result back to the originating conversation on completion
|
||||
# (mirrors the scheduler's ``deliver="origin"`` grammar). Empty by default,
|
||||
# preserving the pull-only behaviour when no delivery target is set.
|
||||
origin: Dict[str, Any] = field(default_factory=dict)
|
||||
# Whether the terminal result has been delivered back to ``origin`` via the
|
||||
# ``on_complete`` callback. Used by restart reconciliation to distinguish a
|
||||
# job that COMPLETED-and-was-delivered from one that COMPLETED-but-whose
|
||||
# deliver-back never fired (e.g. the process died between recording the
|
||||
# result and running the callback). Only meaningful when a store persists
|
||||
# ``JobInfo`` across restarts; ``False`` preserves prior behaviour.
|
||||
delivered: bool = False
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[float]:
|
||||
"""Get job duration in seconds."""
|
||||
@@ -43,6 +64,40 @@ class JobInfo:
|
||||
return end_time - self.started_at
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BackgroundJobStore(Protocol):
|
||||
"""Persistence contract for durable background jobs.
|
||||
|
||||
Mirrors the store-protocol pattern used elsewhere in the SDK (e.g. the
|
||||
session/scheduler stores): the core runner stays heavy-import-free and a
|
||||
concrete SQLite implementation lives in the wrapper/bot layer alongside the
|
||||
other durable stores (``OutboundQueue``, DLQ, approvals). Injecting a store
|
||||
is entirely opt-in — when none is supplied the runner behaves exactly as the
|
||||
original pure in-memory implementation.
|
||||
|
||||
Implementations must be safe to call from the runner's worker threads.
|
||||
"""
|
||||
|
||||
def upsert(self, job: JobInfo) -> None:
|
||||
"""Insert or update the persisted record for ``job`` (keyed by id)."""
|
||||
...
|
||||
|
||||
def get(self, job_id: str) -> Optional[JobInfo]:
|
||||
"""Return the persisted :class:`JobInfo`, or ``None`` if unknown."""
|
||||
...
|
||||
|
||||
def list_unreconciled(self) -> List[JobInfo]:
|
||||
"""Return jobs needing reconciliation after a restart.
|
||||
|
||||
Specifically: jobs left ``PENDING`` or ``RUNNING`` at the last write
|
||||
(their process died before or during the run — both are orphaned and
|
||||
reconciled to ``LOST``) and jobs ``COMPLETED`` with an ``origin`` that
|
||||
were never marked ``delivered``. Returned in a stable order for
|
||||
deterministic replay.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class BackgroundJobManager:
|
||||
"""
|
||||
Manages background jobs for agent tasks.
|
||||
@@ -58,6 +113,8 @@ class BackgroundJobManager:
|
||||
self,
|
||||
max_workers: int = 4,
|
||||
auto_background_threshold: float = 5.0,
|
||||
*,
|
||||
store: Optional[BackgroundJobStore] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the background job manager.
|
||||
@@ -66,13 +123,36 @@ class BackgroundJobManager:
|
||||
max_workers: Maximum number of concurrent background jobs
|
||||
auto_background_threshold: Time in seconds after which a job
|
||||
should be automatically backgrounded
|
||||
store: Optional durable :class:`BackgroundJobStore`. When supplied,
|
||||
every job state transition is persisted so jobs survive a
|
||||
process restart and can be reconciled via
|
||||
:meth:`reconcile_on_start`. When ``None`` (the default) the
|
||||
runner is pure in-memory exactly as before — zero overhead, no
|
||||
behaviour change.
|
||||
"""
|
||||
self.max_workers = max_workers
|
||||
self.auto_background_threshold = auto_background_threshold
|
||||
self._store = store
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self._jobs: Dict[str, JobInfo] = {}
|
||||
self._futures: Dict[str, Future] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _persist(self, job: JobInfo) -> None:
|
||||
"""Best-effort persist of a job's current state to the store.
|
||||
|
||||
A persistence failure must never crash a worker thread or lose the
|
||||
in-memory job, so exceptions are swallowed and logged. No-op when no
|
||||
store is configured (the default), keeping the hot path free.
|
||||
"""
|
||||
if self._store is None:
|
||||
return
|
||||
try:
|
||||
self._store.upsert(job)
|
||||
except Exception as e: # noqa: BLE001 — persistence must never crash worker
|
||||
logger.debug(
|
||||
"Persisting job %s failed (non-fatal): %s", job.job_id, e,
|
||||
)
|
||||
|
||||
@property
|
||||
def active_jobs(self) -> int:
|
||||
@@ -87,6 +167,8 @@ class BackgroundJobManager:
|
||||
self,
|
||||
func: Callable[[], Any],
|
||||
job_id: Optional[str] = None,
|
||||
on_complete: Optional[Callable[["JobInfo"], None]] = None,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Start a new background job.
|
||||
@@ -94,6 +176,18 @@ class BackgroundJobManager:
|
||||
Args:
|
||||
func: The function to execute in the background
|
||||
job_id: Optional custom job ID (auto-generated if not provided)
|
||||
on_complete: Optional callback invoked with the terminal
|
||||
:class:`JobInfo` once the job reaches a terminal state
|
||||
(completed or failed). This is the observable completion
|
||||
signal a gateway subscribes to in order to proactively
|
||||
deliver a background result back to the originating chat
|
||||
without an active turn. Best-effort — any exception it
|
||||
raises is swallowed so a delivery failure never crashes the
|
||||
worker thread. Defaults to ``None`` (pull-only behaviour).
|
||||
origin: Optional origin/delivery context (e.g. ``platform``,
|
||||
``chat_id``, ``thread_id``, ``session_id``, ``deliver``)
|
||||
stored on the :class:`JobInfo` so ``on_complete`` can route
|
||||
the result. Empty/omitted preserves today's behaviour.
|
||||
|
||||
Returns:
|
||||
The job ID
|
||||
@@ -103,28 +197,60 @@ class BackgroundJobManager:
|
||||
job_info = JobInfo(
|
||||
job_id=job_id,
|
||||
status=JobStatus.PENDING,
|
||||
origin=dict(origin) if origin else {},
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job_info
|
||||
# Persist the PENDING record before it starts so a crash between spawn
|
||||
# and first run still leaves a recoverable trace (reconciled as LOST).
|
||||
self._persist(job_info)
|
||||
|
||||
def _fire_complete() -> None:
|
||||
if on_complete is None:
|
||||
return
|
||||
with self._lock:
|
||||
info = self._jobs.get(job_id)
|
||||
if info is None:
|
||||
return
|
||||
try:
|
||||
on_complete(info)
|
||||
# Record successful deliver-back so restart reconciliation does
|
||||
# not re-deliver an already-delivered result.
|
||||
with self._lock:
|
||||
info.delivered = True
|
||||
self._persist(info)
|
||||
except Exception as e: # noqa: BLE001 — delivery must never crash worker
|
||||
logger.debug(
|
||||
"on_complete callback for job %s raised (non-fatal): %s",
|
||||
job_id, e,
|
||||
)
|
||||
|
||||
def _run_job():
|
||||
with self._lock:
|
||||
self._jobs[job_id].status = JobStatus.RUNNING
|
||||
self._jobs[job_id].started_at = time.time()
|
||||
job = self._jobs[job_id]
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = time.time()
|
||||
self._persist(job)
|
||||
|
||||
try:
|
||||
result = func()
|
||||
with self._lock:
|
||||
self._jobs[job_id].status = JobStatus.COMPLETED
|
||||
self._jobs[job_id].completed_at = time.time()
|
||||
self._jobs[job_id].result = result
|
||||
job = self._jobs[job_id]
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.completed_at = time.time()
|
||||
job.result = result
|
||||
self._persist(job)
|
||||
_fire_complete()
|
||||
return result
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self._jobs[job_id].status = JobStatus.FAILED
|
||||
self._jobs[job_id].completed_at = time.time()
|
||||
self._jobs[job_id].error = str(e)
|
||||
job = self._jobs[job_id]
|
||||
job.status = JobStatus.FAILED
|
||||
job.completed_at = time.time()
|
||||
job.error = str(e)
|
||||
self._persist(job)
|
||||
_fire_complete()
|
||||
raise
|
||||
|
||||
future = self._executor.submit(_run_job)
|
||||
@@ -147,9 +273,12 @@ class BackgroundJobManager:
|
||||
KeyError: If job_id is not found
|
||||
"""
|
||||
with self._lock:
|
||||
if job_id not in self._jobs:
|
||||
raise KeyError(f"Job {job_id} not found")
|
||||
return self._jobs[job_id].status
|
||||
if job_id in self._jobs:
|
||||
return self._jobs[job_id].status
|
||||
job = self._store.get(job_id) if self._store else None
|
||||
if job is None:
|
||||
raise KeyError(f"Job {job_id} not found")
|
||||
return job.status
|
||||
|
||||
def get_job_info(self, job_id: str) -> JobInfo:
|
||||
"""
|
||||
@@ -165,9 +294,12 @@ class BackgroundJobManager:
|
||||
KeyError: If job_id is not found
|
||||
"""
|
||||
with self._lock:
|
||||
if job_id not in self._jobs:
|
||||
raise KeyError(f"Job {job_id} not found")
|
||||
return self._jobs[job_id]
|
||||
if job_id in self._jobs:
|
||||
return self._jobs[job_id]
|
||||
job = self._store.get(job_id) if self._store else None
|
||||
if job is None:
|
||||
raise KeyError(f"Job {job_id} not found")
|
||||
return job
|
||||
|
||||
def get_result(self, job_id: str, timeout: Optional[float] = None) -> Any:
|
||||
"""
|
||||
@@ -213,8 +345,108 @@ class BackgroundJobManager:
|
||||
if cancelled:
|
||||
self._jobs[job_id].status = JobStatus.CANCELLED
|
||||
self._jobs[job_id].completed_at = time.time()
|
||||
return cancelled
|
||||
|
||||
job = self._jobs[job_id]
|
||||
else:
|
||||
job = None
|
||||
if job is not None:
|
||||
self._persist(job)
|
||||
return cancelled
|
||||
|
||||
def reconcile_on_start(
|
||||
self,
|
||||
redeliver: Optional[Callable[[JobInfo], Any]] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""Reconcile persisted jobs after a process restart.
|
||||
|
||||
Closes the durability gap where a restart mid-job silently drops work
|
||||
and its deferred deliver-back. For every job the store reports as
|
||||
unreconciled:
|
||||
|
||||
- A job left ``RUNNING`` when its process died is marked
|
||||
:attr:`JobStatus.LOST` (a terminal, queryable state) and persisted.
|
||||
The abandoned work is not automatically re-run — that decision is
|
||||
left to the caller, who can inspect ``LOST`` jobs and re-submit.
|
||||
- A job that ``COMPLETED`` with an ``origin`` but was never
|
||||
``delivered`` has ``redeliver`` invoked with its :class:`JobInfo`,
|
||||
re-firing the deliver-back-to-origin the crash interrupted. On a
|
||||
successful redeliver the job is marked ``delivered`` and persisted so
|
||||
it is not delivered twice on the next restart.
|
||||
|
||||
Every persisted job is also re-hydrated into this manager's in-memory
|
||||
map so it remains queryable by id after the restart.
|
||||
|
||||
No-op (returns zero counts) when no store is configured. Safe to call
|
||||
once at startup, mirroring ``OutboundQueue.drain`` wiring.
|
||||
|
||||
Args:
|
||||
redeliver: Callable invoked with a COMPLETED-but-undelivered
|
||||
:class:`JobInfo` to replay its deliver-back. Best-effort — an
|
||||
exception it raises is logged and the job is left undelivered
|
||||
for a future retry, never crashing startup. When ``None``,
|
||||
undelivered jobs are re-hydrated but not delivered.
|
||||
|
||||
Returns:
|
||||
Counts dict: ``{"lost": n, "redelivered": n, "rehydrated": n}``.
|
||||
"""
|
||||
counts = {"lost": 0, "redelivered": 0, "rehydrated": 0}
|
||||
if self._store is None:
|
||||
return counts
|
||||
|
||||
try:
|
||||
unreconciled = list(self._store.list_unreconciled())
|
||||
except Exception as e: # noqa: BLE001 — reconciliation must never crash startup
|
||||
logger.warning("Listing unreconciled jobs failed: %s", e)
|
||||
return counts
|
||||
|
||||
for job in unreconciled:
|
||||
# A job left RUNNING (or still PENDING) when its process died was
|
||||
# orphaned by the crash. Transition it to the terminal LOST state
|
||||
# *before* it becomes visible in the in-memory map, so a concurrent
|
||||
# get_status never observes the transient RUNNING/PENDING status the
|
||||
# caller is specifically reconciling away. Stamp completed_at so the
|
||||
# terminal record is age-evictable by cleanup_completed().
|
||||
orphaned = job.status in (JobStatus.RUNNING, JobStatus.PENDING)
|
||||
if orphaned:
|
||||
prior_status = job.status.value
|
||||
job.status = JobStatus.LOST
|
||||
if job.completed_at is None:
|
||||
job.completed_at = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._jobs[job.job_id] = job
|
||||
counts["rehydrated"] += 1
|
||||
|
||||
if orphaned:
|
||||
self._persist(job)
|
||||
counts["lost"] += 1
|
||||
logger.info(
|
||||
"Reconciled orphaned %s job %s as LOST",
|
||||
prior_status, job.job_id,
|
||||
)
|
||||
elif (
|
||||
job.status == JobStatus.COMPLETED
|
||||
and job.origin
|
||||
and not job.delivered
|
||||
):
|
||||
if redeliver is None:
|
||||
continue
|
||||
try:
|
||||
redeliver(job)
|
||||
job.delivered = True
|
||||
self._persist(job)
|
||||
counts["redelivered"] += 1
|
||||
logger.info(
|
||||
"Re-delivered undelivered completed job %s to origin",
|
||||
job.job_id,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — redeliver is best-effort
|
||||
logger.warning(
|
||||
"Re-delivery for job %s failed (will retry next "
|
||||
"restart): %s", job.job_id, e,
|
||||
)
|
||||
|
||||
return counts
|
||||
|
||||
def list_jobs(self, status: Optional[JobStatus] = None) -> Dict[str, JobInfo]:
|
||||
"""
|
||||
List all jobs, optionally filtered by status.
|
||||
@@ -234,12 +466,23 @@ class BackgroundJobManager:
|
||||
if info.status == status
|
||||
}
|
||||
|
||||
# Terminal states whose in-memory records are age-evictable. LOST is
|
||||
# included so reconciled orphans (see reconcile_on_start) do not accumulate
|
||||
# unbounded across restarts — without it every restart would leak a fresh
|
||||
# batch of LOST entries for the process lifetime.
|
||||
_EVICTABLE_STATES = (
|
||||
JobStatus.COMPLETED,
|
||||
JobStatus.FAILED,
|
||||
JobStatus.CANCELLED,
|
||||
JobStatus.LOST,
|
||||
)
|
||||
|
||||
def cleanup_completed(self, max_age: float = 3600.0) -> int:
|
||||
"""
|
||||
Remove completed jobs older than max_age seconds.
|
||||
Remove terminal jobs older than max_age seconds.
|
||||
|
||||
Args:
|
||||
max_age: Maximum age in seconds for completed jobs
|
||||
max_age: Maximum age in seconds for terminal jobs
|
||||
|
||||
Returns:
|
||||
Number of jobs removed
|
||||
@@ -250,7 +493,7 @@ class BackgroundJobManager:
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for job_id, info in self._jobs.items():
|
||||
if info.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
|
||||
if info.status in self._EVICTABLE_STATES:
|
||||
if info.completed_at and (now - info.completed_at) > max_age:
|
||||
to_remove.append(job_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Bot Protocols and Configuration for PraisonAI Agents.
|
||||
|
||||
Defines the interfaces and configuration for messaging bot implementations.
|
||||
All implementations live in the praisonai wrapper package.
|
||||
All implementations live in the ``praisonai-bot`` package (``praisonai_bot.bots``).
|
||||
Backward-compatible imports: ``from praisonai.bots import Bot`` via wrapper shims.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -29,6 +30,10 @@ from .protocols import (
|
||||
PlatformCapabilities,
|
||||
WebhookVerifierProtocol,
|
||||
)
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
@@ -59,7 +64,19 @@ from .agent_reply import (
|
||||
AgentReply,
|
||||
extract_presentation,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
from .config import BotConfig, BotOSConfig, DisplayPolicy, resolve_display_policy
|
||||
from .silence import (
|
||||
SILENT_REPLY_TOKEN,
|
||||
is_intentional_silence_response,
|
||||
BotLoopPolicy,
|
||||
BotLoopGuard,
|
||||
)
|
||||
from .run_status import (
|
||||
RunPhase,
|
||||
RunStatusController,
|
||||
StallState,
|
||||
StallWatchdog,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BotProtocol",
|
||||
@@ -70,6 +87,8 @@ __all__ = [
|
||||
"BotUser",
|
||||
"BotChannel",
|
||||
"BotConfig",
|
||||
"DisplayPolicy",
|
||||
"resolve_display_policy",
|
||||
"MessageType",
|
||||
"ChatCommandInfo",
|
||||
"ChatCommandProtocol",
|
||||
@@ -92,6 +111,8 @@ __all__ = [
|
||||
"adapt_presentation",
|
||||
"PlatformCapabilities",
|
||||
"WebhookVerifierProtocol",
|
||||
"BasePlatformAdapter",
|
||||
"SendResult",
|
||||
"InteractiveContext",
|
||||
"InteractiveRegistry",
|
||||
"InteractiveHandler",
|
||||
@@ -106,4 +127,12 @@ __all__ = [
|
||||
"REPLY_NAMESPACE",
|
||||
"AgentReply",
|
||||
"extract_presentation",
|
||||
"SILENT_REPLY_TOKEN",
|
||||
"is_intentional_silence_response",
|
||||
"BotLoopPolicy",
|
||||
"BotLoopGuard",
|
||||
"RunPhase",
|
||||
"RunStatusController",
|
||||
"StallState",
|
||||
"StallWatchdog",
|
||||
]
|
||||
@@ -7,7 +7,7 @@ Channels render the presentation via the existing per-platform renderers and
|
||||
fall back to text where rich UI is unsupported.
|
||||
|
||||
This is a core protocol with no heavy implementations: channel rendering and
|
||||
session wiring live in the wrapper (praisonai). Producers return one of:
|
||||
session wiring live in ``praisonai-bot`` (``praisonai_bot.bots``). Producers return one of:
|
||||
|
||||
* ``str`` -> plain text (unchanged, fully backward compatible)
|
||||
* ``MessagePresentation`` -> interactive UI (text fallback derived from text blocks)
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
BasePlatformAdapter — the inheritable base class for gateway channel adapters.
|
||||
|
||||
This module provides a single, dependency-free base class that new messaging
|
||||
channels can subclass. It defines a **minimal abstract contract** (four
|
||||
methods) and a wide set of **capability-driven default implementations** so
|
||||
that a new channel inherits robust delivery, chunking, retry/backoff, typing
|
||||
heartbeats and graceful edit/delete fallbacks for free.
|
||||
|
||||
Design goals:
|
||||
|
||||
- **Protocol-only / dependency-free:** lives in core (``praisonai-agents``) so
|
||||
third-party adapters can type against it without importing the wrapper. No
|
||||
heavy platform SDK is imported here; the default orchestration is pure logic
|
||||
that only calls the abstract ``send``/``edit`` primitives.
|
||||
- **Capability-driven:** all default behaviour keys off
|
||||
:class:`PlatformCapabilities` read via ``getattr`` so the shared delivery
|
||||
path has zero per-platform branching and degrades gracefully when a feature
|
||||
is absent.
|
||||
|
||||
Adding a channel becomes: subclass, implement ~4 methods, declare
|
||||
capabilities — and inherit robust delivery/chunking/typing/retry.
|
||||
|
||||
Example::
|
||||
|
||||
from praisonaiagents.bots import BasePlatformAdapter, PlatformCapabilities
|
||||
|
||||
class AcmeBot(BasePlatformAdapter):
|
||||
capabilities = PlatformCapabilities(
|
||||
supports_edit=True, supports_typing=True, max_message_length=4096,
|
||||
)
|
||||
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
...
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
...
|
||||
|
||||
async def send(self, chat_id, content, *, reply_to=None, metadata=None):
|
||||
message_id = await acme_api.send(chat_id, content)
|
||||
return SendResult(ok=True, message_id=message_id, chat_id=chat_id)
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
# chunking / retry / typing / edit fallbacks inherited, capability-gated
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .protocols import PlatformCapabilities
|
||||
|
||||
__all__ = [
|
||||
"SendResult",
|
||||
"BasePlatformAdapter",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
"""Result of a single outbound send/edit through an adapter.
|
||||
|
||||
A small, transport-neutral value returned by :meth:`BasePlatformAdapter.send`
|
||||
and the default delivery helpers so callers can react uniformly regardless
|
||||
of platform.
|
||||
|
||||
Attributes:
|
||||
ok: Whether the send succeeded.
|
||||
message_id: Platform message id of the last message sent (if any). For
|
||||
a chunked delivery this is the id of the final chunk.
|
||||
chat_id: The chat/channel the message was delivered to.
|
||||
message_ids: All platform message ids produced (one per chunk).
|
||||
error: Human-readable error string when ``ok`` is False.
|
||||
retry_after: Suggested seconds to wait before retrying (from the
|
||||
platform's rate-limit response, if provided).
|
||||
metadata: Additional platform-specific result details.
|
||||
"""
|
||||
|
||||
ok: bool = True
|
||||
message_id: Optional[str] = None
|
||||
chat_id: Optional[str] = None
|
||||
message_ids: List[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
retry_after: Optional[float] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a plain dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"message_id": self.message_id,
|
||||
"chat_id": self.chat_id,
|
||||
"message_ids": list(self.message_ids),
|
||||
"error": self.error,
|
||||
"retry_after": self.retry_after,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
def _chunk_text(text: str, max_length: int) -> List[str]:
|
||||
"""Split *text* into chunks of at most *max_length* characters.
|
||||
|
||||
Prefers paragraph then line then hard-split boundaries. Pure and
|
||||
dependency-free; a subclass can override :meth:`BasePlatformAdapter.chunk`
|
||||
to use a richer platform-aware splitter.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
if max_length <= 0 or len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
current = ""
|
||||
for para in re.split(r"\n\n", text):
|
||||
candidate = (current + "\n\n" + para) if current else para
|
||||
if len(candidate) <= max_length:
|
||||
current = candidate
|
||||
continue
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
if len(para) <= max_length:
|
||||
current = para
|
||||
continue
|
||||
# Paragraph itself too long — split on lines, then hard-split.
|
||||
for line in para.split("\n"):
|
||||
cand = (current + "\n" + line) if current else line
|
||||
if len(cand) <= max_length:
|
||||
current = cand
|
||||
continue
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
while len(line) > max_length:
|
||||
chunks.append(line[:max_length])
|
||||
line = line[max_length:]
|
||||
current = line
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""Inheritable base class for gateway platform/channel adapters.
|
||||
|
||||
Subclasses implement a **minimal abstract contract** (four methods) and
|
||||
inherit **capability-driven default behaviour** (chunking, retry/backoff,
|
||||
typing heartbeat, edit/delete fallbacks, formatting). Override any default
|
||||
only when the platform can do better.
|
||||
|
||||
Declare platform features by setting the class attribute
|
||||
:attr:`capabilities` to a :class:`PlatformCapabilities` instance. The
|
||||
default delivery path reads capabilities via ``getattr`` so it degrades
|
||||
gracefully when a flag is absent.
|
||||
"""
|
||||
|
||||
#: Platform capabilities descriptor. Subclasses override with their own.
|
||||
capabilities: PlatformCapabilities = PlatformCapabilities()
|
||||
|
||||
#: Max retry attempts for the default resilient delivery loop.
|
||||
max_retries: int = 3
|
||||
#: Base backoff (seconds) for exponential retry when no ``retry_after``.
|
||||
retry_base_delay: float = 0.5
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Required contract — subclasses MUST implement these four primitives. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Establish the platform connection.
|
||||
|
||||
Args:
|
||||
is_reconnect: True when re-establishing after a drop.
|
||||
|
||||
Returns:
|
||||
True if the connection was established successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""Tear down the platform connection and release resources."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send(
|
||||
self,
|
||||
chat_id: Any,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
*,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a single message to *chat_id* (one API call, no chunking).
|
||||
|
||||
This is the low-level primitive the default delivery machinery builds
|
||||
on. Implementations should send *content* as-is and return a
|
||||
:class:`SendResult`. Chunking/retry/typing are handled by
|
||||
:meth:`deliver`; implementers need not repeat them here.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_chat_info(self, chat_id: Any) -> Dict[str, Any]:
|
||||
"""Return metadata about a chat/channel (at least an ``id`` key)."""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Capability helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _cap(self, name: str, default: Any) -> Any:
|
||||
"""Read a capability flag with a fallback default."""
|
||||
return getattr(self.capabilities, name, default)
|
||||
|
||||
@property
|
||||
def max_message_length(self) -> int:
|
||||
"""Platform max message length (0 = unlimited)."""
|
||||
return int(self._cap("max_message_length", 4096) or 0)
|
||||
|
||||
@property
|
||||
def supports_edit(self) -> bool:
|
||||
"""Whether the platform supports in-place message edits."""
|
||||
return bool(self._cap("supports_edit", False))
|
||||
|
||||
@property
|
||||
def supports_typing(self) -> bool:
|
||||
"""Whether the platform supports typing indicators."""
|
||||
return bool(self._cap("supports_typing", False))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Default-implemented, capability-driven — override only to improve. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def format_message(self, text: str) -> str:
|
||||
"""Apply per-platform formatting. Default: identity."""
|
||||
return text
|
||||
|
||||
def chunk(self, text: str) -> List[str]:
|
||||
"""Split *text* to respect the platform max length.
|
||||
|
||||
Default uses a paragraph/line-aware splitter. Override to use a
|
||||
richer platform-aware chunker (e.g. code-fence preserving).
|
||||
"""
|
||||
return _chunk_text(text, self.max_message_length)
|
||||
|
||||
async def send_typing(self, chat_id: Any) -> None:
|
||||
"""Send a typing indicator. Default no-op unless capability set."""
|
||||
return None
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: Any,
|
||||
message_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
) -> SendResult:
|
||||
"""Edit an existing message.
|
||||
|
||||
Default behaviour when the platform does not support edits: report
|
||||
``ok=False`` with a "not supported" error so the caller can re-send.
|
||||
Platforms that support edits should override this.
|
||||
"""
|
||||
if not self.supports_edit:
|
||||
return SendResult(
|
||||
ok=False,
|
||||
chat_id=chat_id,
|
||||
error="edit_not_supported",
|
||||
metadata={"message_id": message_id},
|
||||
)
|
||||
raise NotImplementedError(
|
||||
"capabilities.supports_edit is True but edit_message is not "
|
||||
"implemented; override edit_message in the adapter."
|
||||
)
|
||||
|
||||
async def delete_message(self, chat_id: Any, message_id: str) -> bool:
|
||||
"""Delete a message. Default: not supported → returns False."""
|
||||
return False
|
||||
|
||||
async def deliver(
|
||||
self,
|
||||
chat_id: Any,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
*,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
typing: bool = True,
|
||||
) -> SendResult:
|
||||
"""Robustly deliver *content*, inheriting all shared machinery.
|
||||
|
||||
Applies (all capability-gated, via :meth:`send`):
|
||||
- per-platform formatting (:meth:`format_message`);
|
||||
- long-message chunking (:meth:`chunk`) when *content* is text;
|
||||
- typing heartbeat before the first chunk (if supported);
|
||||
- retry with exponential backoff honouring ``retry_after``.
|
||||
|
||||
Non-text (dict) content is passed straight through to :meth:`send`
|
||||
without chunking/formatting.
|
||||
"""
|
||||
if typing and self.supports_typing:
|
||||
try:
|
||||
await self.send_typing(chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(content, str):
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.chunk(formatted)
|
||||
else:
|
||||
chunks = [content] # type: ignore[list-item]
|
||||
|
||||
aggregate = SendResult(ok=True, chat_id=chat_id)
|
||||
for index, chunk in enumerate(chunks):
|
||||
result = await self._send_with_retry(
|
||||
chat_id,
|
||||
chunk,
|
||||
reply_to=reply_to if index == 0 else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
if not result.ok:
|
||||
result.message_ids = aggregate.message_ids + result.message_ids
|
||||
return result
|
||||
if result.message_id:
|
||||
aggregate.message_ids.append(result.message_id)
|
||||
aggregate.message_id = result.message_id
|
||||
aggregate.metadata = {"chunks": len(chunks)}
|
||||
return aggregate
|
||||
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
chat_id: Any,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
*,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send one chunk with retry/backoff honouring ``retry_after``."""
|
||||
last: SendResult = SendResult(ok=False, chat_id=chat_id, error="unsent")
|
||||
attempts = max(1, int(self.max_retries))
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
result = await self.send(
|
||||
chat_id, content, reply_to=reply_to, metadata=metadata
|
||||
)
|
||||
except Exception as exc: # transport error — retry
|
||||
result = SendResult(ok=False, chat_id=chat_id, error=str(exc))
|
||||
if result.ok:
|
||||
return result
|
||||
last = result
|
||||
if attempt < attempts - 1:
|
||||
delay = result.retry_after
|
||||
if delay is None:
|
||||
delay = self.retry_base_delay * (2 ** attempt)
|
||||
if delay and delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
return last
|
||||
@@ -198,6 +198,225 @@ class BotConfig:
|
||||
return channel_id in self.allowed_channels
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display / verbosity policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Allowed values for each DisplayPolicy field.
|
||||
_DISPLAY_STREAMING = {"off", "draft", "progress"}
|
||||
_DISPLAY_TOOL_PROGRESS = {"off", "inline"}
|
||||
_DISPLAY_FOOTER = {"off", "compact"}
|
||||
|
||||
# Platform → tier mapping. Tiers describe the rendering surface so sensible
|
||||
# defaults can be applied without per-platform configuration.
|
||||
# edit — edit-capable personal chats (stream live, hide tool spam)
|
||||
# work — workspace chats (post discrete progress steps)
|
||||
# noedit — no-edit chats (single final message)
|
||||
# batch — batch-only channels (one final message, no interim)
|
||||
_PLATFORM_TIERS: Dict[str, str] = {
|
||||
"telegram": "edit",
|
||||
"discord": "edit",
|
||||
"whatsapp": "noedit",
|
||||
"slack": "work",
|
||||
"teams": "work",
|
||||
"mattermost": "work",
|
||||
"email": "batch",
|
||||
"sms": "batch",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayPolicy:
|
||||
"""Per-platform display / verbosity policy.
|
||||
|
||||
Describes the operator's *policy* for how output should be presented on a
|
||||
channel (as opposed to ``PlatformCapabilities`` which describes what a
|
||||
platform *can* render).
|
||||
|
||||
Attributes:
|
||||
streaming: Reply streaming mode.
|
||||
``"off"`` (single final message) | ``"draft"`` (edit in place) |
|
||||
``"progress"`` (compact status then final).
|
||||
tool_progress: Whether tool execution progress is surfaced.
|
||||
``"off"`` (hidden) | ``"inline"`` (inline progress bubbles).
|
||||
interim_assistant_messages: Show partial assistant messages, or only
|
||||
the final reply.
|
||||
footer: Runtime footer / status-line appended to replies.
|
||||
``"off"`` | ``"compact"`` (e.g. ``model · ctx% · cwd``).
|
||||
"""
|
||||
|
||||
streaming: str = "off"
|
||||
tool_progress: str = "off"
|
||||
interim_assistant_messages: bool = False
|
||||
footer: str = "off"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"streaming": self.streaming,
|
||||
"tool_progress": self.tool_progress,
|
||||
"interim_assistant_messages": self.interim_assistant_messages,
|
||||
"footer": self.footer,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "DisplayPolicy":
|
||||
"""Create a policy from a (partial) dict, ignoring unknown keys."""
|
||||
base = cls()
|
||||
return cls(
|
||||
streaming=_coerce_choice(
|
||||
data.get("streaming", base.streaming), _DISPLAY_STREAMING, base.streaming
|
||||
),
|
||||
tool_progress=_coerce_choice(
|
||||
data.get("tool_progress", base.tool_progress),
|
||||
_DISPLAY_TOOL_PROGRESS,
|
||||
base.tool_progress,
|
||||
),
|
||||
interim_assistant_messages=_coerce_bool(
|
||||
data.get(
|
||||
"interim_assistant_messages", base.interim_assistant_messages
|
||||
),
|
||||
base.interim_assistant_messages,
|
||||
),
|
||||
footer=_coerce_choice(
|
||||
data.get("footer", base.footer), _DISPLAY_FOOTER, base.footer
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Built-in per-tier defaults. Most operators configure nothing and still get
|
||||
# correct UX on every channel.
|
||||
_TIER_DEFAULTS: Dict[str, DisplayPolicy] = {
|
||||
# edit-capable personal chats: stream live, hide tool spam, no footer
|
||||
"edit": DisplayPolicy(streaming="draft", tool_progress="off", footer="off"),
|
||||
# workspace chats: post discrete progress steps
|
||||
"work": DisplayPolicy(streaming="off", tool_progress="inline", footer="off"),
|
||||
# no-edit chats: single final message
|
||||
"noedit": DisplayPolicy(streaming="off", tool_progress="off", footer="off"),
|
||||
# batch-only channels: one final message, no interim
|
||||
"batch": DisplayPolicy(
|
||||
streaming="off",
|
||||
tool_progress="off",
|
||||
interim_assistant_messages=False,
|
||||
footer="off",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_choice(value: Any, allowed: set, default: str) -> str:
|
||||
"""Return ``value`` if it is an allowed choice, else ``default``."""
|
||||
if isinstance(value, str) and value in allowed:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
# String tokens treated as booleans for text-backed config (YAML/JSON/env).
|
||||
_TRUE_TOKENS = {"true", "1", "yes", "on"}
|
||||
_FALSE_TOKENS = {"false", "0", "no", "off"}
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||
"""Coerce ``value`` to a bool without ``bool("false") is True`` surprises.
|
||||
|
||||
Real booleans pass through. Strings are matched against known truthy/falsy
|
||||
tokens (case-insensitive). Anything else falls back to ``default``.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
token = value.strip().lower()
|
||||
if token in _TRUE_TOKENS:
|
||||
return True
|
||||
if token in _FALSE_TOKENS:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def resolve_display_policy(platform: str, config: Optional[Dict[str, Any]]) -> DisplayPolicy:
|
||||
"""Resolve the effective :class:`DisplayPolicy` for a platform.
|
||||
|
||||
Precedence (highest first):
|
||||
1. explicit ``display.platforms.<platform>.<setting>``
|
||||
2. ``display.<setting>`` global default
|
||||
3. platform-tier default (edit / work / noedit / batch)
|
||||
4. built-in :class:`DisplayPolicy` default
|
||||
|
||||
Args:
|
||||
platform: Platform name, e.g. ``"telegram"``.
|
||||
config: A ``display`` config dict (the value of the ``display:`` block),
|
||||
or a full config dict containing a ``display`` key. ``None`` is
|
||||
treated as empty.
|
||||
|
||||
Returns:
|
||||
The resolved :class:`DisplayPolicy`.
|
||||
"""
|
||||
display = _extract_display(config)
|
||||
|
||||
# Layer 4: built-in default.
|
||||
policy = DisplayPolicy()
|
||||
|
||||
# Layer 3: platform-tier default.
|
||||
tier = _PLATFORM_TIERS.get((platform or "").lower())
|
||||
if tier and tier in _TIER_DEFAULTS:
|
||||
policy = _merge_policy(policy, _TIER_DEFAULTS[tier].to_dict())
|
||||
|
||||
# Layer 2: global default (display.<setting>).
|
||||
global_overrides = {
|
||||
k: v for k, v in display.items() if k != "platforms"
|
||||
}
|
||||
policy = _merge_policy(policy, global_overrides)
|
||||
|
||||
# Layer 1: explicit platform override.
|
||||
platforms = display.get("platforms") or {}
|
||||
if isinstance(platforms, dict):
|
||||
platform_overrides = _lookup_platform(platforms, platform)
|
||||
if isinstance(platform_overrides, dict):
|
||||
policy = _merge_policy(policy, platform_overrides)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def _lookup_platform(platforms: Dict[str, Any], platform: str) -> Any:
|
||||
"""Look up a platform's overrides, tolerating key casing differences.
|
||||
|
||||
Tries the exact key, then the lower-cased key, then a case-insensitive
|
||||
scan so natural config keys (e.g. ``Telegram``) still match ``telegram``.
|
||||
"""
|
||||
if platform in platforms:
|
||||
return platforms[platform]
|
||||
normalized = (platform or "").lower()
|
||||
if normalized in platforms:
|
||||
return platforms[normalized]
|
||||
for key, value in platforms.items():
|
||||
if isinstance(key, str) and key.lower() == normalized:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_display(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Return the ``display`` mapping from ``config``.
|
||||
|
||||
Accepts either the ``display`` block directly or a full config dict
|
||||
containing a ``display`` key.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
if "display" in config and isinstance(config.get("display"), dict):
|
||||
return config["display"]
|
||||
return config
|
||||
|
||||
|
||||
def _merge_policy(policy: DisplayPolicy, overrides: Dict[str, Any]) -> DisplayPolicy:
|
||||
"""Return a new policy with valid ``overrides`` applied over ``policy``."""
|
||||
if not isinstance(overrides, dict):
|
||||
return policy
|
||||
data = policy.to_dict()
|
||||
for key in data:
|
||||
if key in overrides:
|
||||
data[key] = overrides[key]
|
||||
return DisplayPolicy.from_dict(data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotOSConfig:
|
||||
"""Configuration for BotOS — multi-platform bot orchestrator.
|
||||
|
||||
@@ -6,7 +6,7 @@ interactive UI elements (buttons, select menus) across all messaging platforms.
|
||||
This complements the presentation.py render side with symmetric inbound handling.
|
||||
|
||||
This is a core protocol with no heavy implementations - channel-specific
|
||||
callback decoding belongs in the wrapper (praisonai).
|
||||
callback decoding belongs in ``praisonai-bot`` (``praisonai_bot.bots``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Pairing types and protocols for bot user approval.
|
||||
|
||||
Contains lightweight types and protocols for the pairing approval system.
|
||||
Heavy implementations are in the wrapper package.
|
||||
Heavy implementations are in the ``praisonai-bot`` package.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -6,7 +6,7 @@ that channel adapters render as native widgets. Enables structured
|
||||
interactive UI across Telegram, Slack, Discord, and other platforms.
|
||||
|
||||
This is a core protocol with no heavy implementations - channel-specific
|
||||
rendering belongs in the wrapper (praisonai).
|
||||
rendering belongs in ``praisonai-bot`` (``praisonai_bot.bots``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -513,6 +513,33 @@ class PresentationLimits:
|
||||
supports_web_apps=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def whatsapp() -> "PresentationLimits":
|
||||
"""Get WhatsApp Cloud API-specific limits.
|
||||
|
||||
WhatsApp interactive messages support two native shapes:
|
||||
|
||||
* *reply buttons* — at most 3 buttons, each label capped at 20 chars.
|
||||
* *list messages* — a single "menu" whose rows map naturally to select
|
||||
options (up to 10 rows), each row title capped at 24 chars.
|
||||
|
||||
``supports_select`` is True because a native list message renders a
|
||||
``select`` block directly, and the WhatsApp renderer promotes button
|
||||
overflow (>3) into a list message as well. WhatsApp has no markdown and
|
||||
no web-app buttons, so those degrade via ``adapt_presentation``.
|
||||
"""
|
||||
return PresentationLimits(
|
||||
max_buttons=10, # list-message row cap; ≤3 render as reply buttons
|
||||
max_button_rows=1,
|
||||
max_button_label=24, # list-row title hard cap; renderer caps reply buttons at 20
|
||||
max_options=10, # list-message rows
|
||||
max_option_label=24, # list-row title hard cap
|
||||
max_text_length=4096,
|
||||
supports_markdown=False,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
)
|
||||
|
||||
|
||||
def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> PresentationButton:
|
||||
"""Return a copy of a button adapted to the given limits.
|
||||
|
||||
@@ -5,7 +5,8 @@ Defines the interfaces for messaging bot implementations.
|
||||
These protocols enable agents to communicate through messaging platforms
|
||||
like Telegram, Discord, Slack, etc.
|
||||
|
||||
All implementations should live in the praisonai wrapper package.
|
||||
All implementations live in the ``praisonai-bot`` package (``praisonai_bot.bots``).
|
||||
Backward-compatible imports: ``from praisonai.bots import Bot`` via wrapper shims.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -179,7 +180,7 @@ class WebhookVerifierProtocol(Protocol):
|
||||
``PlatformCapabilities.accepts_webhooks`` must expose a verifier and that
|
||||
verifier must pass before dispatch.
|
||||
|
||||
Implementations live in the ``praisonai`` wrapper package and typically
|
||||
Implementations live in the ``praisonai-bot`` package (``praisonai_bot.bots``) and
|
||||
wrap an HMAC comparison over the raw request body.
|
||||
|
||||
Example:
|
||||
@@ -668,8 +669,10 @@ class BotProtocol(Protocol):
|
||||
- Webhook/polling management
|
||||
- User and channel management
|
||||
|
||||
Example usage (implementation in praisonai wrapper):
|
||||
from praisonai.bots import TelegramBot
|
||||
Example usage (implementation in praisonai-bot):
|
||||
|
||||
from praisonai_bot.bots.telegram import TelegramBot
|
||||
# or: from praisonai.bots import TelegramBot # wrapper shim
|
||||
|
||||
bot = TelegramBot(token="...", agent=my_agent)
|
||||
await bot.start()
|
||||
@@ -1118,7 +1121,7 @@ class BotOSProtocol(Protocol):
|
||||
└── Bot (single platform)
|
||||
└── Agent / AgentTeam / AgentFlow (AI brain)
|
||||
|
||||
Implementations live in the ``praisonai`` wrapper package.
|
||||
Implementations live in the ``praisonai-bot`` package (``praisonai_bot.bots``).
|
||||
"""
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Run-status controller and stall watchdog for bot gateways.
|
||||
|
||||
A pure, transport-agnostic run-presentation policy that advances an in-chat
|
||||
reaction (or a status-label edit on channels without reactions) through the
|
||||
agent run lifecycle, plus an armable stall watchdog that surfaces "still
|
||||
working…" / "taking longer than expected" signals when a run goes quiet.
|
||||
|
||||
This complements the one-shot ``AckReactor`` (receipt -> done only) in
|
||||
``praisonai_bot.bots`` with a *progressing* phase state machine driven by the
|
||||
same run lifecycle signals the ack lifecycle and streaming already consume.
|
||||
No new agent instrumentation is required.
|
||||
|
||||
Design (mirrors ``silence.py`` / ``presentation.py``): zero transport imports.
|
||||
Channel adapters inject two capability-gated async callbacks and the controller
|
||||
decides *which* to use from :class:`PlatformCapabilities`:
|
||||
|
||||
* ``set_status_reaction(emoji)`` — used when the channel supports reactions.
|
||||
* ``edit_status_label(label)`` — used to degrade gracefully to a status line.
|
||||
|
||||
Off by default: adapters opt in (``streaming.status_reactions: true``).
|
||||
|
||||
Usage::
|
||||
|
||||
controller = RunStatusController(
|
||||
caps,
|
||||
set_status_reaction=react_fn, # async (emoji: str) -> None
|
||||
edit_status_label=edit_fn, # async (label: str) -> None
|
||||
)
|
||||
await controller.on_phase(RunPhase.QUEUED) # 👀
|
||||
await controller.on_phase(RunPhase.THINKING) # 🧠
|
||||
await controller.on_phase(RunPhase.TOOL) # 🛠️
|
||||
# if the run goes quiet, drive the watchdog from a periodic ticker:
|
||||
await controller.tick(elapsed_s) # ⏳ then ⚠️
|
||||
await controller.on_phase(RunPhase.DONE) # ✅
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .protocols import PlatformCapabilities
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RunPhase(Enum):
|
||||
"""Progressing run phases surfaced to the chat channel.
|
||||
|
||||
Advances QUEUED -> THINKING -> TOOL -> DONE/ERROR. Intermediate
|
||||
transitions are debounced to avoid flicker; terminal transitions
|
||||
(DONE/ERROR) are applied immediately.
|
||||
"""
|
||||
|
||||
QUEUED = "queued"
|
||||
THINKING = "thinking"
|
||||
TOOL = "tool"
|
||||
DONE = "done"
|
||||
ERROR = "error"
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
"""Whether this phase ends the run (applied immediately, no debounce)."""
|
||||
return self in (RunPhase.DONE, RunPhase.ERROR)
|
||||
|
||||
|
||||
class StallState(Enum):
|
||||
"""Watchdog stall states derived from elapsed-since-last-progress time."""
|
||||
|
||||
OK = "ok"
|
||||
SOFT = "soft" # "still working…"
|
||||
HARD = "hard" # "taking longer than expected"
|
||||
|
||||
|
||||
DEFAULT_PHASE_EMOJI = {
|
||||
RunPhase.QUEUED: "👀",
|
||||
RunPhase.THINKING: "🧠",
|
||||
RunPhase.TOOL: "🛠️",
|
||||
RunPhase.DONE: "✅",
|
||||
RunPhase.ERROR: "❌",
|
||||
}
|
||||
|
||||
DEFAULT_STALL_EMOJI = {
|
||||
StallState.SOFT: "⏳",
|
||||
StallState.HARD: "⚠️",
|
||||
}
|
||||
|
||||
DEFAULT_PHASE_LABEL = {
|
||||
RunPhase.QUEUED: "queued",
|
||||
RunPhase.THINKING: "thinking…",
|
||||
RunPhase.TOOL: "using a tool…",
|
||||
RunPhase.DONE: "done",
|
||||
RunPhase.ERROR: "error",
|
||||
}
|
||||
|
||||
DEFAULT_STALL_LABEL = {
|
||||
StallState.SOFT: "still working…",
|
||||
StallState.HARD: "this is taking longer than expected",
|
||||
}
|
||||
|
||||
|
||||
class StallWatchdog:
|
||||
"""Pure soft/hard stall threshold evaluator.
|
||||
|
||||
Maps an ``elapsed_s`` (seconds since the last observed progress) to a
|
||||
:class:`StallState`. Holds no clock of its own — the caller feeds elapsed
|
||||
time from a periodic ticker or the same progress signal that drives phases,
|
||||
keeping this fully deterministic and testable.
|
||||
"""
|
||||
|
||||
def __init__(self, *, soft_s: float = 20.0, hard_s: float = 60.0) -> None:
|
||||
if hard_s < soft_s:
|
||||
logger.warning(
|
||||
"StallWatchdog: hard_s (%s) < soft_s (%s); clamping hard_s up "
|
||||
"to soft_s. SOFT will be unreachable — fix the thresholds.",
|
||||
hard_s,
|
||||
soft_s,
|
||||
)
|
||||
hard_s = soft_s
|
||||
self._soft_s = float(soft_s)
|
||||
self._hard_s = float(hard_s)
|
||||
self._state = StallState.OK
|
||||
|
||||
@property
|
||||
def state(self) -> StallState:
|
||||
"""The most recently evaluated stall state."""
|
||||
return self._state
|
||||
|
||||
def evaluate(self, elapsed_s: float) -> StallState:
|
||||
"""Return the stall state for ``elapsed_s`` seconds without progress."""
|
||||
if elapsed_s >= self._hard_s:
|
||||
state = StallState.HARD
|
||||
elif elapsed_s >= self._soft_s:
|
||||
state = StallState.SOFT
|
||||
else:
|
||||
state = StallState.OK
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear the stall state (call on any real progress / phase change)."""
|
||||
self._state = StallState.OK
|
||||
|
||||
|
||||
ReactionFn = Callable[[str], Awaitable[None]]
|
||||
LabelFn = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
class RunStatusController:
|
||||
"""Transport-agnostic run-status state machine with a stall watchdog.
|
||||
|
||||
Drives a chat-visible status through the run lifecycle. Reactions are used
|
||||
when :attr:`PlatformCapabilities.reactions`-style support is present;
|
||||
otherwise it degrades to editing a single status label. Both rendering
|
||||
callbacks are optional and injected by the channel adapter.
|
||||
|
||||
Args:
|
||||
caps: Platform capabilities used to gate reaction vs label rendering.
|
||||
When ``caps`` exposes a truthy ``supports_reactions``/``reactions``
|
||||
attribute (or dict-style ``reactions`` key) the controller renders
|
||||
reactions; a falsy value forces the label callback. When neither
|
||||
signal is present (e.g. the standard ``PlatformCapabilities``
|
||||
dataclass) the controller defaults to reactions if a reaction
|
||||
callback was injected.
|
||||
set_status_reaction: Async callback ``(emoji) -> None`` to set the
|
||||
single status reaction (implementations typically swap the prior
|
||||
emoji). Optional.
|
||||
edit_status_label: Async callback ``(label) -> None`` to edit a single
|
||||
status line/label. Optional.
|
||||
debounce_ms: Minimum spacing between *intermediate* renders to avoid
|
||||
flicker. Terminal phases (DONE/ERROR) always render immediately.
|
||||
stall_soft_s: Seconds without progress before the soft stall signal.
|
||||
stall_hard_s: Seconds without progress before the hard stall signal.
|
||||
enabled: Master switch. Off by default; adapters opt in.
|
||||
now: Monotonic clock callable (injectable for tests).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
caps: Optional["PlatformCapabilities"] = None,
|
||||
*,
|
||||
set_status_reaction: Optional[ReactionFn] = None,
|
||||
edit_status_label: Optional[LabelFn] = None,
|
||||
debounce_ms: int = 700,
|
||||
stall_soft_s: float = 20.0,
|
||||
stall_hard_s: float = 60.0,
|
||||
enabled: bool = False,
|
||||
now: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
self._caps = caps
|
||||
self._set_reaction = set_status_reaction
|
||||
self._edit_label = edit_status_label
|
||||
self._debounce_s = max(0.0, debounce_ms / 1000.0)
|
||||
self._enabled = enabled
|
||||
self.watchdog = StallWatchdog(soft_s=stall_soft_s, hard_s=stall_hard_s)
|
||||
|
||||
if now is None:
|
||||
import time
|
||||
|
||||
now = time.monotonic
|
||||
self._now = now
|
||||
|
||||
self._phase: Optional[RunPhase] = None
|
||||
self._rendered_phase: Optional[RunPhase] = None
|
||||
self._pending_phase: Optional[RunPhase] = None
|
||||
self._last_render_at: float = 0.0
|
||||
self._stall_shown: StallState = StallState.OK
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether the controller will render anything at all."""
|
||||
if not self._enabled:
|
||||
return False
|
||||
return self._set_reaction is not None or self._edit_label is not None
|
||||
|
||||
@property
|
||||
def phase(self) -> Optional[RunPhase]:
|
||||
"""The last phase supplied via :meth:`on_phase` (may be un-rendered)."""
|
||||
return self._phase
|
||||
|
||||
def _use_reactions(self) -> bool:
|
||||
"""Decide reaction vs label rendering from capabilities and callbacks."""
|
||||
if self._set_reaction is None:
|
||||
return False
|
||||
if self._edit_label is None:
|
||||
return True
|
||||
caps = self._caps
|
||||
if caps is None:
|
||||
return True
|
||||
# Prefer an explicit ``supports_reactions`` attribute, then the
|
||||
# ``reactions`` field used by ``ChannelCapabilities`` (dict) and any
|
||||
# attribute-style caps object. ``PlatformCapabilities`` (the frozen
|
||||
# dataclass) exposes neither today, so absent any signal we default to
|
||||
# preferring reactions when a reaction callback is present.
|
||||
supports = getattr(caps, "supports_reactions", None)
|
||||
if supports is None:
|
||||
supports = getattr(caps, "reactions", None)
|
||||
if supports is None:
|
||||
# Dict-style ChannelCapabilities fallback.
|
||||
get = getattr(caps, "get", None)
|
||||
if callable(get):
|
||||
supports = get("reactions")
|
||||
return bool(supports) if supports is not None else True
|
||||
|
||||
async def _render(self, *, emoji: str, label: str) -> None:
|
||||
"""Render the given emoji/label via the capability-gated callback."""
|
||||
try:
|
||||
if self._use_reactions() and self._set_reaction is not None:
|
||||
await self._set_reaction(emoji)
|
||||
elif self._edit_label is not None:
|
||||
await self._edit_label(label)
|
||||
except Exception as e: # pragma: no cover - defensive; never break a run
|
||||
logger.debug("RunStatusController: render failed: %s", e)
|
||||
|
||||
async def on_phase(self, phase: RunPhase) -> None:
|
||||
"""Advance to ``phase``.
|
||||
|
||||
Intermediate phases are debounced (coalesced) to avoid flicker; the
|
||||
most recent pending phase wins when the debounce window elapses (drive
|
||||
via :meth:`tick`). Terminal phases render immediately and clear any
|
||||
pending intermediate phase and stall signal.
|
||||
"""
|
||||
self._phase = phase
|
||||
# Any phase change is real progress -> clear the stall watchdog.
|
||||
self.watchdog.reset()
|
||||
self._stall_shown = StallState.OK
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if phase.is_terminal:
|
||||
self._pending_phase = None
|
||||
await self._apply_phase(phase)
|
||||
return
|
||||
|
||||
if self._rendered_phase is None:
|
||||
# First intermediate phase renders immediately for responsiveness.
|
||||
await self._apply_phase(phase)
|
||||
return
|
||||
|
||||
now = self._now()
|
||||
if now - self._last_render_at >= self._debounce_s:
|
||||
await self._apply_phase(phase)
|
||||
else:
|
||||
# Coalesce: remember the latest phase; flushed on the next tick().
|
||||
self._pending_phase = phase
|
||||
|
||||
async def _apply_phase(self, phase: RunPhase) -> None:
|
||||
self._rendered_phase = phase
|
||||
self._pending_phase = None
|
||||
self._last_render_at = self._now()
|
||||
await self._render(
|
||||
emoji=DEFAULT_PHASE_EMOJI.get(phase, ""),
|
||||
label=DEFAULT_PHASE_LABEL.get(phase, phase.value),
|
||||
)
|
||||
|
||||
async def tick(self, elapsed_s: float) -> None:
|
||||
"""Drive debounced phase flush and the stall watchdog.
|
||||
|
||||
Call periodically while a run is in flight. ``elapsed_s`` is the time
|
||||
since the last observed progress (phase change or run event). Flushes a
|
||||
coalesced pending phase once the debounce window has elapsed, then arms
|
||||
the soft/hard stall signal when the corresponding threshold is crossed.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
# Flush a debounced pending intermediate phase if it's now due.
|
||||
if self._pending_phase is not None:
|
||||
now = self._now()
|
||||
if now - self._last_render_at >= self._debounce_s:
|
||||
await self._apply_phase(self._pending_phase)
|
||||
|
||||
# Do not arm stall signals once the run has terminated.
|
||||
if self._phase is not None and self._phase.is_terminal:
|
||||
return
|
||||
|
||||
state = self.watchdog.evaluate(elapsed_s)
|
||||
if state == self._stall_shown:
|
||||
return
|
||||
self._stall_shown = state
|
||||
if state in DEFAULT_STALL_EMOJI:
|
||||
await self._render(
|
||||
emoji=DEFAULT_STALL_EMOJI[state],
|
||||
label=DEFAULT_STALL_LABEL[state],
|
||||
)
|
||||
# state == OK: cleared implicitly by the next phase render.
|
||||
@@ -3,8 +3,22 @@ Silence protocol for bot gateways.
|
||||
|
||||
Provides a canonical contract for agents to signal intentional silence
|
||||
(no reply) in group/ambient channels. Zero dependencies.
|
||||
|
||||
Also provides :class:`BotLoopGuard`, a pure sliding-window pair-budget guard
|
||||
that breaks runaway bot-to-bot reply loops (two bots answering each other
|
||||
forever). Like :func:`praisonaiagents.bots.protocols.evaluate_channel_health`,
|
||||
it is a dependency-free decision primitive: the wrapper/bot layer feeds it the
|
||||
pair identities and drops the turn on a ``False`` verdict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Deque, Dict, Optional, Tuple
|
||||
|
||||
SILENT_REPLY_TOKEN = "NO_REPLY"
|
||||
|
||||
_MARKERS = frozenset({"NO_REPLY", "[SILENT]", "SILENT"})
|
||||
@@ -49,4 +63,172 @@ def is_intentional_silence_response(text: str | None) -> bool:
|
||||
if inner == "SILENT": # Only [SILENT] is a valid bracketed marker
|
||||
return True
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotLoopPolicy:
|
||||
"""Sliding-window pair budget for bot-to-bot loop protection.
|
||||
|
||||
A gateway configured to accept bot-authored inbound messages (multi-bot
|
||||
rooms, cross-posted assistants, an @-mentioning bot) can ping-pong with
|
||||
another bot indefinitely: each reply is a valid admitted turn, so per-user
|
||||
serialisation, the admission ceiling and outbound rate limiting never break
|
||||
a strictly sequential A->B->A loop. This policy declares a per-pair budget
|
||||
that :class:`BotLoopGuard` enforces.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether the guard suppresses loops. When ``False`` the guard
|
||||
always allows the reply (legacy behaviour).
|
||||
max_events_per_window: Maximum number of bot-authored exchanges allowed
|
||||
for a single pair within ``window_seconds`` before the pair is put
|
||||
on cooldown. Must be >= 1.
|
||||
window_seconds: Length of the sliding window in seconds.
|
||||
cooldown_seconds: How long a pair stays suppressed once the budget is
|
||||
exceeded. During cooldown every reply to that pair is dropped.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
max_events_per_window: int = 20
|
||||
window_seconds: float = 60.0
|
||||
cooldown_seconds: float = 60.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# A budget below 1 would suppress the very first exchange for every
|
||||
# pair, permanently blocking all bot traffic. Clamp to a safe minimum
|
||||
# so a config typo cannot silently deadlock the gateway.
|
||||
if self.max_events_per_window < 1:
|
||||
self.max_events_per_window = 1
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[dict]) -> "BotLoopPolicy":
|
||||
"""Build a policy from a config mapping (YAML/kwargs), tolerating None.
|
||||
|
||||
Unknown keys are ignored so a forward-compatible config never breaks
|
||||
construction. ``None`` yields the enabled default policy.
|
||||
"""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
max_events_per_window=int(data.get("max_events_per_window", 20)),
|
||||
window_seconds=float(data.get("window_seconds", 60.0)),
|
||||
cooldown_seconds=float(data.get("cooldown_seconds", 60.0)),
|
||||
)
|
||||
|
||||
|
||||
def _pair_key(self_bot_id: str, sender_bot_id: str) -> Tuple[str, str]:
|
||||
"""Canonical, direction-independent key for a participant pair.
|
||||
|
||||
A<->B and B<->A collapse to one key so the budget counts the *exchange*,
|
||||
not each one-way hop, matching how a loop actually accrues.
|
||||
"""
|
||||
a, b = str(self_bot_id), str(sender_bot_id)
|
||||
return (a, b) if a <= b else (b, a)
|
||||
|
||||
|
||||
class BotLoopGuard:
|
||||
"""Pure sliding-window pair-budget guard against bot-to-bot reply loops.
|
||||
|
||||
Tracks each participant pair (our bot identity <-> the other sender's bot
|
||||
identity) in both directions. Once a pair exceeds
|
||||
``policy.max_events_per_window`` bot-authored events inside
|
||||
``policy.window_seconds`` it is suppressed for ``policy.cooldown_seconds``.
|
||||
|
||||
Only bot-authored inbound turns should be observed: human-authored
|
||||
messages, single-bot deployments and normal below-budget bot replies are
|
||||
unaffected, so the guard is zero-cost on the common path (the caller simply
|
||||
skips :meth:`observe` when the sender is human).
|
||||
|
||||
Zero dependencies; deterministic under an injected ``now`` for testing —
|
||||
exactly like :func:`evaluate_channel_health`.
|
||||
|
||||
Example::
|
||||
|
||||
guard = BotLoopGuard(BotLoopPolicy(max_events_per_window=20))
|
||||
if sender_is_bot and not guard.observe(
|
||||
self_bot_id="mybot", sender_bot_id="otherbot", now=time.time()
|
||||
):
|
||||
return # drop the turn: record history like an admission drop
|
||||
"""
|
||||
|
||||
def __init__(self, policy: Optional[BotLoopPolicy] = None) -> None:
|
||||
self._policy = policy or BotLoopPolicy()
|
||||
# Per-pair event timestamps within the sliding window.
|
||||
self._events: Dict[Tuple[str, str], Deque[float]] = {}
|
||||
# Per-pair cooldown expiry timestamps.
|
||||
self._cooldowns: Dict[Tuple[str, str], float] = {}
|
||||
# Guards shared state against concurrent inbound turns (async/threaded
|
||||
# gateways can observe the same pair from multiple workers).
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether the guard actively suppresses loops."""
|
||||
return self._policy.enabled
|
||||
|
||||
def observe(
|
||||
self,
|
||||
*,
|
||||
self_bot_id: str,
|
||||
sender_bot_id: str,
|
||||
now: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""Record a bot-authored exchange and decide whether to allow the reply.
|
||||
|
||||
Args:
|
||||
self_bot_id: Our bot's identity on the platform.
|
||||
sender_bot_id: The other (bot) sender's identity.
|
||||
now: Current timestamp (injectable for testing). Defaults to
|
||||
``time.time()``.
|
||||
|
||||
Returns:
|
||||
``True`` to allow the reply, ``False`` to suppress it (the pair is
|
||||
in cooldown or just exceeded its budget). Always ``True`` when the
|
||||
policy is disabled.
|
||||
"""
|
||||
if not self._policy.enabled:
|
||||
return True
|
||||
if now is None:
|
||||
now = time.time()
|
||||
|
||||
key = _pair_key(self_bot_id, sender_bot_id)
|
||||
|
||||
with self._lock:
|
||||
# Still cooling down? Suppress without recording, so a persistent
|
||||
# flood cannot keep resetting/extending the window mid-cooldown.
|
||||
cooldown_until = self._cooldowns.get(key)
|
||||
if cooldown_until is not None:
|
||||
if now < cooldown_until:
|
||||
return False
|
||||
# Cooldown elapsed: clear it and start a fresh window below.
|
||||
del self._cooldowns[key]
|
||||
self._events.pop(key, None)
|
||||
|
||||
window = self._events.setdefault(key, deque())
|
||||
window.append(now)
|
||||
|
||||
# Evict events older than the sliding window.
|
||||
cutoff = now - self._policy.window_seconds
|
||||
while window and window[0] <= cutoff:
|
||||
window.popleft()
|
||||
|
||||
if len(window) > self._policy.max_events_per_window:
|
||||
# Budget exceeded: open a cooldown and suppress this reply.
|
||||
self._cooldowns[key] = now + self._policy.cooldown_seconds
|
||||
self._events.pop(key, None)
|
||||
return False
|
||||
|
||||
# Reclaim the deque if the window emptied out (a long-idle pair),
|
||||
# so a gateway seeing many unique identities does not accumulate
|
||||
# empty deques indefinitely.
|
||||
if not window:
|
||||
self._events.pop(key, None)
|
||||
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all tracked pairs and cooldowns (e.g. on gateway restart)."""
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
self._cooldowns.clear()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""CLI extension protocols for wrapper ↔ code boundaries (C8.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TemplateStoreProtocol(Protocol):
|
||||
"""Template persistence accessed from repatriated CLI features."""
|
||||
|
||||
def list_templates(self, *, category: Optional[str] = None) -> list[Any]:
|
||||
...
|
||||
|
||||
def get_template(self, name: str) -> Any:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionStoreProtocol(Protocol):
|
||||
"""Optional session backend for interactive TUI paths."""
|
||||
|
||||
def load(self, session_id: str) -> Optional[Any]:
|
||||
...
|
||||
|
||||
def save(self, session_id: str, data: Any) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ServeHandlerProtocol(Protocol):
|
||||
"""Dispatch serve subcommands without direct wrapper imports in code."""
|
||||
|
||||
def handle(self, args: list[str]) -> int:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BotGatewayFacadeProtocol(Protocol):
|
||||
"""Future praisonai-bot package entry for gateway/bot CLI (C8.4 Phase 9)."""
|
||||
|
||||
def handle_gateway(self, args: list[str]) -> int:
|
||||
...
|
||||
|
||||
def handle_bot(self, args: list[str]) -> int:
|
||||
...
|
||||
@@ -32,6 +32,7 @@ __all__ = [
|
||||
"PlanningConfig",
|
||||
"MultiAgentPlanningConfig",
|
||||
"ReflectionConfig",
|
||||
"RulesConfig",
|
||||
"GuardrailConfig",
|
||||
"WebConfig",
|
||||
"OutputConfig",
|
||||
@@ -120,6 +121,7 @@ _MODULE_MAP = {
|
||||
"PlanningConfig": "feature_configs",
|
||||
"MultiAgentPlanningConfig": "feature_configs",
|
||||
"ReflectionConfig": "feature_configs",
|
||||
"RulesConfig": "feature_configs",
|
||||
"GuardrailConfig": "feature_configs",
|
||||
"WebConfig": "feature_configs",
|
||||
"OutputConfig": "feature_configs",
|
||||
|
||||
@@ -38,6 +38,11 @@ from enum import Enum
|
||||
# Import AutonomyConfig from canonical location (no circular dep)
|
||||
from ..agent.autonomy import AutonomyConfig
|
||||
|
||||
# Default tool output limit (16000 chars ≈ 4000 tokens)
|
||||
# Single source of truth shared by OutputConfig.tool_output_limit and
|
||||
# ToolConfig.output_limit (and re-exported for agent.py) to prevent drift.
|
||||
DEFAULT_TOOL_OUTPUT_LIMIT = 16000
|
||||
|
||||
# TYPE_CHECKING import to avoid circular dependency
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
@@ -438,6 +443,47 @@ class ReflectionConfig:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RulesConfig:
|
||||
"""
|
||||
Configuration for auto-applying per-project rules/instructions.
|
||||
|
||||
Discovers and injects per-repository instruction files (AGENTS.md,
|
||||
CLAUDE.md, .praisonai/rules/*.md, etc.) into the agent system prompt
|
||||
on every run, honouring the existing activation modes (always, glob,
|
||||
manual, ai_decision), priority ordering, and a character budget.
|
||||
|
||||
Usage:
|
||||
# Simple enable (auto-discover + apply)
|
||||
Agent(rules=True)
|
||||
|
||||
# Opt out (reproducible/sandboxed runs)
|
||||
Agent(rules=False)
|
||||
|
||||
# With config
|
||||
Agent(rules=RulesConfig(
|
||||
char_budget=6000,
|
||||
files=["docs/CONVENTIONS.md"],
|
||||
))
|
||||
"""
|
||||
# Character budget for injected rules context (None = manager default)
|
||||
char_budget: Optional[int] = None
|
||||
|
||||
# Extra instruction file paths/globs to include beyond auto-discovery
|
||||
files: Optional[List[str]] = None
|
||||
|
||||
# Optional workspace path override (defaults to current working directory)
|
||||
workspace_path: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"char_budget": self.char_budget,
|
||||
"files": list(self.files or []),
|
||||
"workspace_path": self.workspace_path,
|
||||
}
|
||||
|
||||
|
||||
class GuardrailAction(str, Enum):
|
||||
"""Action to take when guardrail fails."""
|
||||
RETRY = "retry"
|
||||
@@ -651,7 +697,9 @@ class OutputConfig:
|
||||
|
||||
# Tool output limit - maximum characters for tool output
|
||||
# Default: 16000 chars (≈ 4000 tokens) to prevent runaway tool outputs
|
||||
tool_output_limit: int = 16000
|
||||
# Alias reconciled with ToolConfig.output_limit (ToolConfig wins when supplied);
|
||||
# default sourced from DEFAULT_TOOL_OUTPUT_LIMIT to avoid drift.
|
||||
tool_output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.tool_output_limit, int) or self.tool_output_limit <= 0:
|
||||
@@ -722,6 +770,12 @@ class ExecutionConfig:
|
||||
"""
|
||||
# Iteration limits
|
||||
max_iter: int = 20
|
||||
|
||||
# Unified multi-step tool budget (loop steps). When set, this is the single
|
||||
# knob honoured identically by BOTH tool-execution loops (OpenAI-native and
|
||||
# LiteLLM/custom) instead of the divergent max_iter/max_tool_calls_per_turn
|
||||
# defaults. None = fall back to max_iter (backward compatible).
|
||||
max_steps: Optional[int] = None
|
||||
|
||||
# Rate limiting
|
||||
max_rpm: Optional[int] = None
|
||||
@@ -787,6 +841,9 @@ class ExecutionConfig:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization processing with deprecation warnings and validation."""
|
||||
# Validate the unified step budget early (before any early returns below).
|
||||
if self.max_steps is not None and self.max_steps < 1:
|
||||
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
|
||||
# Handle context_compaction serialization round-trip
|
||||
if isinstance(self.context_compaction, dict):
|
||||
from ..context.policy import ContextCompactionPolicy
|
||||
@@ -836,10 +893,33 @@ class ExecutionConfig:
|
||||
if self.retry_jitter < 0:
|
||||
raise ValueError("ExecutionConfig.retry_jitter must be non-negative.")
|
||||
|
||||
def resolved_max_steps(self) -> int:
|
||||
"""Single, unified step budget honoured by both tool-execution loops.
|
||||
|
||||
Returns max_steps when explicitly set, otherwise falls back to max_iter
|
||||
so existing defaults keep working (backward compatible).
|
||||
"""
|
||||
if self.max_steps is not None:
|
||||
return self.max_steps
|
||||
return self.max_iter
|
||||
|
||||
def resolved_max_tool_calls(self) -> int:
|
||||
"""Per-turn tool-call guardrail (max parallel tool calls in one response).
|
||||
|
||||
This is intentionally *independent* of ``max_steps``: ``max_steps``
|
||||
bounds outer-loop iterations (one LLM round-trip each), whereas this
|
||||
caps how many tool calls may fire within a single LLM response. Coupling
|
||||
them would let a single parallel-tool response exhaust the step budget
|
||||
(e.g. ``max_steps=5`` truncating after one round of 5 parallel calls).
|
||||
"""
|
||||
return self.max_tool_calls_per_turn
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"max_iter": self.max_iter,
|
||||
"max_steps": self.max_steps,
|
||||
"max_tool_calls_per_turn": self.max_tool_calls_per_turn,
|
||||
"max_rpm": self.max_rpm,
|
||||
"max_execution_time": self.max_execution_time,
|
||||
"max_retry_limit": self.max_retry_limit,
|
||||
@@ -873,6 +953,8 @@ class ExecutionConfig:
|
||||
|
||||
return cls(
|
||||
max_iter=data.get("max_iter", 20),
|
||||
max_steps=data.get("max_steps", None),
|
||||
max_tool_calls_per_turn=data.get("max_tool_calls_per_turn", 10),
|
||||
max_rpm=data.get("max_rpm", None),
|
||||
max_execution_time=data.get("max_execution_time", None),
|
||||
max_retry_limit=data.get("max_retry_limit", 2),
|
||||
@@ -987,7 +1069,7 @@ class ToolConfig:
|
||||
parallel: bool = False
|
||||
|
||||
# Tool output handling and artifact storage
|
||||
output_limit: int = 16000 # Maximum bytes before spilling to artifact store
|
||||
output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT # Maximum bytes before spilling to artifact store
|
||||
output_max_lines: Optional[int] = None # Maximum lines before spilling
|
||||
output_direction: str = "both" # Truncation direction: "head", "tail", or "both"
|
||||
enable_artifacts: bool = False # Whether to enable artifact storage (default False for backward compat)
|
||||
@@ -1041,7 +1123,7 @@ class ToolConfig:
|
||||
timeout=data.get("timeout"),
|
||||
retry_policy=retry_policy,
|
||||
parallel=data.get("parallel", False),
|
||||
output_limit=data.get("output_limit", 16000),
|
||||
output_limit=data.get("output_limit", DEFAULT_TOOL_OUTPUT_LIMIT),
|
||||
output_max_lines=data.get("output_max_lines"),
|
||||
output_direction=data.get("output_direction", "both"),
|
||||
enable_artifacts=data.get("enable_artifacts", False),
|
||||
@@ -1682,6 +1764,7 @@ __all__ = [
|
||||
"KnowledgeConfig",
|
||||
"PlanningConfig",
|
||||
"ReflectionConfig",
|
||||
"RulesConfig",
|
||||
"GuardrailConfig",
|
||||
"WebConfig",
|
||||
"OutputConfig",
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from .protocols import FrameworkAdapterProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseFrameworkAdapter:
|
||||
"""Base class for framework adapters providing common functionality."""
|
||||
@@ -18,10 +22,33 @@ class BaseFrameworkAdapter:
|
||||
requires_tools_extra: bool = False
|
||||
is_router: bool = False
|
||||
|
||||
# Adapters with a native async ``run`` path set this True and override
|
||||
# ``arun``. When False (default), ``arun`` offloads the sync ``run`` to a
|
||||
# bounded, per-adapter thread pool so one slow run cannot starve the
|
||||
# process-wide default executor used by ``asyncio.to_thread``.
|
||||
SUPPORTS_ASYNC: bool = False
|
||||
|
||||
# Capability flags read by the wrapper's capability call sites instead of
|
||||
# hardcoded framework-name string checks. An adapter advertises what it can
|
||||
# actually do; the wrapper asks the adapter, not its name. Defaults are
|
||||
# False so existing/third-party adapters remain second-class only when they
|
||||
# do not opt in (backward compatible).
|
||||
#
|
||||
# SUPPORTS_WORKFLOW: adapter can execute native workflow YAML.
|
||||
# SUPPORTS_RUNTIME_FEATURES: adapter supports cli_backend / runtime /
|
||||
# models.*.runtime / providers.*.runtime_default.
|
||||
SUPPORTS_WORKFLOW: bool = False
|
||||
SUPPORTS_RUNTIME_FEATURES: bool = False
|
||||
|
||||
# Bounded per-adapter offload pool. Kept small on purpose: framework runs
|
||||
# are long-lived, so a large pool provides no benefit and just hides the
|
||||
# lack of a native async path. Overridable per subclass / instance.
|
||||
_THREAD_OFFLOAD_MAX_WORKERS: int = 4
|
||||
|
||||
DEFAULT_MODEL = "openai/gpt-4o-mini"
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
self._thread_pool: Optional[ThreadPoolExecutor] = None
|
||||
|
||||
def _resolve_llm(self, spec: Any, llm_config: Optional[List[Dict]]) -> str:
|
||||
"""Resolve a model name from per-agent spec and shared llm_config."""
|
||||
@@ -61,6 +88,29 @@ class BaseFrameworkAdapter:
|
||||
def setup(self, *, framework_tag: str) -> None:
|
||||
pass
|
||||
|
||||
def _get_thread_pool(self) -> ThreadPoolExecutor:
|
||||
"""Lazily create the bounded, per-adapter offload executor."""
|
||||
# Defensive: tolerate subclasses that don't call super().__init__().
|
||||
if getattr(self, "_thread_pool", None) is None:
|
||||
self._thread_pool = ThreadPoolExecutor(
|
||||
max_workers=self._THREAD_OFFLOAD_MAX_WORKERS,
|
||||
thread_name_prefix=f"{self.name or 'framework'}-arun",
|
||||
)
|
||||
return self._thread_pool
|
||||
|
||||
async def _thread_offload_run(self, *args: Any, **kwargs: Any) -> str:
|
||||
"""Run the sync ``run`` on this adapter's bounded thread pool.
|
||||
|
||||
Unlike ``asyncio.to_thread`` (which shares the process-wide default
|
||||
executor), this isolates the offload so one slow framework run cannot
|
||||
starve every other async caller in the process.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
pool = self._get_thread_pool()
|
||||
return await loop.run_in_executor(
|
||||
pool, lambda: self.run(*args, **kwargs)
|
||||
)
|
||||
|
||||
async def arun(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
@@ -72,8 +122,15 @@ class BaseFrameworkAdapter:
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
return await asyncio.to_thread(
|
||||
self.run,
|
||||
if not self.SUPPORTS_ASYNC:
|
||||
logger.warning(
|
||||
"Adapter %r has no native async path; delegating to a bounded "
|
||||
"thread pool (max_workers=%d). Concurrent async callers will "
|
||||
"contend for this pool.",
|
||||
self.name or type(self).__name__,
|
||||
self._THREAD_OFFLOAD_MAX_WORKERS,
|
||||
)
|
||||
return await self._thread_offload_run(
|
||||
config,
|
||||
llm_config,
|
||||
topic,
|
||||
@@ -84,7 +141,20 @@ class BaseFrameworkAdapter:
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
pool = getattr(self, "_thread_pool", None)
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False)
|
||||
self._thread_pool = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
# Best-effort: shut down the offload pool when the adapter is garbage
|
||||
# collected so idle worker threads do not outlive the adapter in
|
||||
# long-lived services that forget to call cleanup(). Guarded because
|
||||
# __del__ can run during interpreter shutdown when globals are gone.
|
||||
try:
|
||||
self.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resolve_alias(self) -> str:
|
||||
return self.name
|
||||
@@ -5,10 +5,11 @@ Provides protocols and base classes for building gateway/control plane
|
||||
implementations that coordinate multi-agent deployments.
|
||||
|
||||
This module contains only protocols and lightweight utilities.
|
||||
Heavy implementations live in the praisonai wrapper package.
|
||||
Heavy implementations live in the ``praisonai-bot`` package (C9); the
|
||||
``praisonai`` wrapper re-exports them for full-stack installs.
|
||||
|
||||
Gap S2: WebSocketGateway is re-exported here for convenience but requires
|
||||
the praisonai wrapper package to be installed.
|
||||
``praisonai-bot`` (or ``pip install praisonai``) to be installed.
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -61,6 +62,11 @@ from .protocols import (
|
||||
GatewayConcurrencyPolicyProtocol,
|
||||
GatewayConcurrencyPolicy, # backward-compat alias
|
||||
ConcurrencyLimitPolicy,
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
RateLimitDecision,
|
||||
RateLimitPolicyProtocol,
|
||||
RateLimitPolicy, # backward-compat alias
|
||||
SlidingWindowRateLimitPolicy,
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
current_epoch,
|
||||
DrainMarkerPolicy,
|
||||
@@ -85,6 +91,20 @@ from .protocols import (
|
||||
ProtocolHelloOk,
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
# Out-of-process platform-connector relay (Issue #2485)
|
||||
CapabilityDescriptor,
|
||||
RelayTransport,
|
||||
# Gateway pipeline span-tracing seam (Issue #2716)
|
||||
GATEWAY_TRACE_STAGES,
|
||||
GatewayTraceHook,
|
||||
NullGatewayTraceHook,
|
||||
NULL_GATEWAY_TRACE_HOOK,
|
||||
resolve_trace_hook,
|
||||
# Gateway self-lifecycle command guardrail (Issue #2753)
|
||||
LifecycleCommandDecision,
|
||||
LifecycleCommandPolicyProtocol,
|
||||
LifecycleCommandPolicy, # backward-compat alias
|
||||
LifecycleCommandGuardPolicy,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
@@ -96,6 +116,7 @@ from .hooks import (
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
ApiConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Push config
|
||||
@@ -111,35 +132,46 @@ _lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load heavy gateway implementations from praisonai wrapper.
|
||||
|
||||
Gap S2: Re-export WebSocketGateway from praisonai wrapper for convenience.
|
||||
This allows downstream packages to import from praisonaiagents.gateway
|
||||
without needing to know about the praisonai wrapper.
|
||||
"""Lazy load heavy gateway implementations from praisonai-bot (C9).
|
||||
|
||||
Gap S2: Re-export WebSocketGateway for convenience. Prefer
|
||||
``praisonai_bot.gateway`` or ``pip install praisonai`` for full stack.
|
||||
"""
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "WebSocketGateway":
|
||||
try:
|
||||
from praisonai_bot.gateway import WebSocketGateway
|
||||
_lazy_cache[name] = WebSocketGateway
|
||||
return WebSocketGateway
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonai.gateway import WebSocketGateway
|
||||
_lazy_cache[name] = WebSocketGateway
|
||||
return WebSocketGateway
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"WebSocketGateway requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
"WebSocketGateway requires praisonai-bot or praisonai. "
|
||||
"Install with: pip install praisonai-bot or pip install praisonai"
|
||||
)
|
||||
|
||||
|
||||
if name == "GatewaySession":
|
||||
try:
|
||||
from praisonai_bot.gateway import GatewaySession
|
||||
_lazy_cache[name] = GatewaySession
|
||||
return GatewaySession
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonai.gateway import GatewaySession
|
||||
_lazy_cache[name] = GatewaySession
|
||||
return GatewaySession
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"GatewaySession requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
"GatewaySession requires praisonai-bot or praisonai. "
|
||||
"Install with: pip install praisonai-bot or pip install praisonai"
|
||||
)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -195,6 +227,11 @@ __all__ = [
|
||||
"GatewayConcurrencyPolicyProtocol",
|
||||
"GatewayConcurrencyPolicy",
|
||||
"ConcurrencyLimitPolicy",
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
"RateLimitDecision",
|
||||
"RateLimitPolicyProtocol",
|
||||
"RateLimitPolicy",
|
||||
"SlidingWindowRateLimitPolicy",
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
"current_epoch",
|
||||
"DrainMarkerPolicy",
|
||||
@@ -219,6 +256,20 @@ __all__ = [
|
||||
"ProtocolHelloOk",
|
||||
"GapInfo",
|
||||
"ResumeSnapshot",
|
||||
# Out-of-process platform-connector relay (Issue #2485)
|
||||
"CapabilityDescriptor",
|
||||
"RelayTransport",
|
||||
# Gateway pipeline span-tracing seam (Issue #2716)
|
||||
"GATEWAY_TRACE_STAGES",
|
||||
"GatewayTraceHook",
|
||||
"NullGatewayTraceHook",
|
||||
"NULL_GATEWAY_TRACE_HOOK",
|
||||
"resolve_trace_hook",
|
||||
# Gateway self-lifecycle command guardrail (Issue #2753)
|
||||
"LifecycleCommandDecision",
|
||||
"LifecycleCommandPolicyProtocol",
|
||||
"LifecycleCommandPolicy",
|
||||
"LifecycleCommandGuardPolicy",
|
||||
# Inbound trigger / webhook contract (Issue #2281)
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
@@ -228,6 +279,7 @@ __all__ = [
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ApiConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
"PushConfig",
|
||||
|
||||
@@ -211,6 +211,44 @@ class PushConfig:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiConfig:
|
||||
"""Configuration for additive protocol surfaces on the gateway app.
|
||||
|
||||
All surfaces are opt-in. When both are False (default), no extra routes
|
||||
are mounted and the gateway behaves exactly as before.
|
||||
|
||||
Attributes:
|
||||
openai: Serve OpenAI-compatible endpoints
|
||||
(``/v1/chat/completions``, ``/v1/responses``, ``/v1/models``)
|
||||
backed by the gateway's live agents and sessions.
|
||||
mcp: Serve an MCP JSON-RPC endpoint (``/mcp``) exposing the gateway's
|
||||
registered agents as callable tools.
|
||||
"""
|
||||
|
||||
openai: bool = False
|
||||
mcp: bool = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether any additional protocol surface is enabled."""
|
||||
return bool(self.openai or self.mcp)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {"openai": self.openai, "mcp": self.mcp}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "ApiConfig":
|
||||
"""Create from a parsed ``gateway.api`` mapping (tolerant of None)."""
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
openai=bool(data.get("openai", False)),
|
||||
mcp=bool(data.get("mcp", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Configuration for the gateway server.
|
||||
@@ -260,8 +298,20 @@ class GatewayConfig:
|
||||
max_concurrent_runs: int = 0 # Aggregate concurrency ceiling (0 = unlimited)
|
||||
queue_depth: int = 0 # Bounded wait queue when at the ceiling
|
||||
overflow_policy: str = "reject" # reject | queue | shed_oldest
|
||||
# Issue #2620: pre-auth edge protections for internet-exposed deployments.
|
||||
# Cap concurrent *unauthenticated* WebSocket connections per source IP so a
|
||||
# hostile client cannot park many half-open sockets up to max_connections
|
||||
# (0 = disabled). Loopback is always exempt at the enforcement layer.
|
||||
preauth_max_connections_per_ip: int = 32
|
||||
# Close a connection after this many unauthorized frames and log-sample the
|
||||
# rest so a hostile client cannot flood logs / burn per-frame work
|
||||
# (0 = disabled).
|
||||
max_unauthorized_frames: int = 10
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
# Additive protocol surfaces (OpenAI-compatible / MCP) served on the same
|
||||
# app and auth. Opt-in; disabled by default so the gateway is unchanged.
|
||||
api: ApiConfig = field(default_factory=ApiConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -291,6 +341,16 @@ class GatewayConfig:
|
||||
raise ValueError(
|
||||
"overflow_policy must be one of 'reject', 'queue', 'shed_oldest'"
|
||||
)
|
||||
if self.preauth_max_connections_per_ip < 0:
|
||||
raise ValueError(
|
||||
"preauth_max_connections_per_ip must be >= 0 (use 0 to disable "
|
||||
"the pre-auth connection budget)"
|
||||
)
|
||||
if self.max_unauthorized_frames < 0:
|
||||
raise ValueError(
|
||||
"max_unauthorized_frames must be >= 0 (use 0 to disable the "
|
||||
"unauthorized-frame flood guard)"
|
||||
)
|
||||
|
||||
@property
|
||||
def has_scope_policy(self) -> bool:
|
||||
@@ -362,8 +422,11 @@ class GatewayConfig:
|
||||
"max_concurrent_runs": self.max_concurrent_runs,
|
||||
"queue_depth": self.queue_depth,
|
||||
"overflow_policy": self.overflow_policy,
|
||||
"preauth_max_connections_per_ip": self.preauth_max_connections_per_ip,
|
||||
"max_unauthorized_frames": self.max_unauthorized_frames,
|
||||
"push": self.push.to_dict(),
|
||||
"scope_policy_enabled": self.has_scope_policy,
|
||||
"api": self.api.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -543,7 +606,14 @@ class MultiChannelGatewayConfig:
|
||||
max_concurrent_runs=int(gw_data.get("max_concurrent_runs", 0) or 0),
|
||||
queue_depth=int(gw_data.get("queue_depth", 0) or 0),
|
||||
overflow_policy=str(gw_data.get("overflow_policy", "reject") or "reject"),
|
||||
preauth_max_connections_per_ip=int(
|
||||
gw_data.get("preauth_max_connections_per_ip", 32)
|
||||
),
|
||||
max_unauthorized_frames=int(
|
||||
gw_data.get("max_unauthorized_frames", 10)
|
||||
),
|
||||
auth_scopes=auth_scopes,
|
||||
api=ApiConfig.from_dict(gw_data.get("api")),
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -5,13 +5,15 @@ Defines the interfaces for gateway/control plane implementations.
|
||||
These protocols enable multi-agent coordination, session management,
|
||||
and real-time communication.
|
||||
|
||||
All implementations should live in the praisonai wrapper package.
|
||||
All implementations live in the ``praisonai-bot`` package (``praisonai_bot.gateway``).
|
||||
The ``praisonai`` wrapper provides backward-compatible shims.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
@@ -20,6 +22,7 @@ from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
@@ -66,9 +69,19 @@ class GatewayCloseCode(str, Enum):
|
||||
advertised ``max_buffered_bytes`` policy (a genuinely slow/stalled
|
||||
consumer). The server evicts it so its backlog cannot grow without
|
||||
bound or stall delivery to healthy clients.
|
||||
CREDENTIALS_ROTATED: The shared gateway secret this session
|
||||
authenticated under is no longer the active secret (an operator
|
||||
rotated ``auth_token`` and hot-reloaded, or otherwise revoked it).
|
||||
The server force-closes every session stamped with a stale secret
|
||||
so a leaked/revoked credential stops working within one reload
|
||||
cycle, without a full process restart. Clients should
|
||||
re-authenticate (see :attr:`ConnectRecoveryStep.REAUTHENTICATE`)
|
||||
and reconnect with fresh credentials rather than backing off as if
|
||||
the server were down.
|
||||
"""
|
||||
|
||||
SLOW_CONSUMER = "slow_consumer"
|
||||
CREDENTIALS_ROTATED = "credentials_rotated"
|
||||
|
||||
|
||||
class ConnectRecoveryStep(str, Enum):
|
||||
@@ -507,7 +520,7 @@ class GatewayProtocol(Protocol):
|
||||
The gateway coordinates communication between clients and agents,
|
||||
manages sessions, and provides health/presence tracking.
|
||||
|
||||
Example usage (implementation in praisonai wrapper):
|
||||
Example usage (implementation in praisonai_bot.gateway):
|
||||
from praisonai.gateway import WebSocketGateway
|
||||
|
||||
gateway = WebSocketGateway(port=8765)
|
||||
@@ -917,7 +930,7 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
are persisted before sending and can be retried on failure. This provides
|
||||
crash-safe at-least-once delivery for channel replies.
|
||||
|
||||
Example usage (implementation in praisonai wrapper):
|
||||
Example usage (implementation in praisonai_bot.gateway):
|
||||
from praisonai.bots import OutboundQueue
|
||||
|
||||
outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")
|
||||
@@ -1737,7 +1750,7 @@ class OutboundMessengerProtocol(Protocol):
|
||||
built-in ``send_message`` tool can resolve it. It bridges to the existing
|
||||
delivery stack (DeliveryRouter, HomeChannelRegistry, outbox, mirroring).
|
||||
|
||||
Example usage (implementation in praisonai wrapper)::
|
||||
Example usage (implementation in praisonai_bot.gateway)::
|
||||
|
||||
messenger = BotOutboundMessenger(bot, resolver, router)
|
||||
token = register_outbound_messenger(messenger)
|
||||
@@ -2313,6 +2326,213 @@ class ConcurrencyLimitPolicy:
|
||||
GatewayConcurrencyPolicy = GatewayConcurrencyPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
#
|
||||
# Rate limiting completes the gateway's policy-protocol family (send, idle,
|
||||
# drain, concurrency). Like its siblings it is a pure, import-free decision
|
||||
# over typed facts — an identity, a scope (endpoint class / channel / tenant)
|
||||
# and a timestamp — returning a closed :class:`RateLimitDecision`. Core ships
|
||||
# a config-driven sliding-window default (:class:`SlidingWindowRateLimitPolicy`)
|
||||
# that reproduces today's built-in behaviour; the wrapper limiters adopt the
|
||||
# protocol and developers can inject their own (per-tenant, distributed,
|
||||
# cost-based) without importing wrapper internals. Core maps a ``limited``
|
||||
# decision onto ``ConnectErrorCode.RATE_LIMITED`` +
|
||||
# ``HelloError.retry_after_seconds``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitDecision:
|
||||
"""Result of a rate-limit evaluation.
|
||||
|
||||
Attributes:
|
||||
allowed: Whether the request may proceed.
|
||||
retry_after_seconds: Backoff hint populated when ``allowed`` is
|
||||
``False`` (seconds until the caller may retry); ``None`` when
|
||||
allowed.
|
||||
"""
|
||||
|
||||
allowed: bool
|
||||
retry_after_seconds: Optional[float] = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RateLimitPolicyProtocol(Protocol):
|
||||
"""Protocol for gateway rate-limit / throttle admission decisions.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's admission
|
||||
paths. The wrapper supplies typed facts (the caller ``identity``, the
|
||||
``scope`` — an endpoint class, channel, or tenant token — and the current
|
||||
``now`` timestamp) and the policy decides whether the request is allowed
|
||||
or ``limited`` with a ``retry_after_seconds`` hint. Concrete state and
|
||||
enforcement (sliding windows, token buckets, a Redis-backed per-tenant
|
||||
quota) live in the implementation; this contract keeps the *decision*
|
||||
injectable and testable in isolation, symmetric with
|
||||
:class:`SendPolicyProtocol` / :class:`GatewayConcurrencyPolicyProtocol`.
|
||||
|
||||
A config-driven default (:class:`SlidingWindowRateLimitPolicy`) is
|
||||
provided for the common "N requests per window per identity" case; core
|
||||
maps a ``limited`` decision onto ``ConnectErrorCode.RATE_LIMITED`` and
|
||||
``HelloError.retry_after_seconds``.
|
||||
"""
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
now: float,
|
||||
) -> RateLimitDecision:
|
||||
"""Return a :class:`RateLimitDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class SlidingWindowRateLimitPolicy:
|
||||
"""Config-driven sliding-window rate-limit policy.
|
||||
|
||||
The default referenced by ``gateway.rate_limit`` blocks in
|
||||
``gateway.yaml`` and the ``WebSocketGateway(..., rate_limit_policy=...)``
|
||||
Python surface. It is intentionally minimal and dependency-free so the
|
||||
decision lives in core and is provable in isolation; heavy wrapper
|
||||
limiters (``gateway/rate_limiter.py`` sliding window,
|
||||
``bots/_rate_limit.py`` token bucket) may adopt this protocol while
|
||||
keeping their own state and side effects.
|
||||
|
||||
The decision, keyed by ``(scope, identity)``:
|
||||
|
||||
* ``allowed`` while fewer than ``max_requests`` have been seen in the
|
||||
current ``window_seconds`` window.
|
||||
* Once the window count exceeds ``max_requests``, the key enters a
|
||||
``lockout_seconds`` cooldown and every :meth:`check` returns
|
||||
``allowed=False`` with a ``retry_after_seconds`` hint until it elapses.
|
||||
|
||||
A ``max_requests`` of ``0`` disables limiting entirely (every request is
|
||||
allowed) — the legacy default when no rate limit is configured.
|
||||
|
||||
This class is not internally synchronised; the wrapper owns any locking
|
||||
it needs for concurrent hot paths (the built-in limiters already do).
|
||||
|
||||
State ownership: per-``(scope, identity)`` window/lockout entries are
|
||||
reclaimed lazily — a key's entry is dropped or overwritten the next time
|
||||
that key is checked. It keeps one entry per *active* key and is intended
|
||||
for a bounded identity space (endpoint classes, authenticated tenants). A
|
||||
wrapper exposing it to an unbounded/untrusted identity space (e.g. raw
|
||||
per-IP keys) owns periodic reclamation, exactly as it owns locking.
|
||||
|
||||
Example::
|
||||
|
||||
SlidingWindowRateLimitPolicy(max_requests=5, window_seconds=60,
|
||||
lockout_seconds=300)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_requests: int = 0,
|
||||
window_seconds: float = 60.0,
|
||||
lockout_seconds: float = 0.0,
|
||||
):
|
||||
try:
|
||||
ceiling = int(max_requests)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"max_requests must be an integer, got {max_requests!r}"
|
||||
) from err
|
||||
if ceiling < 0:
|
||||
raise ValueError(f"max_requests must be >= 0, got {max_requests!r}")
|
||||
try:
|
||||
window = float(window_seconds)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"window_seconds must be a number, got {window_seconds!r}"
|
||||
) from err
|
||||
if window <= 0:
|
||||
raise ValueError(
|
||||
f"window_seconds must be > 0, got {window_seconds!r}"
|
||||
)
|
||||
try:
|
||||
lockout = float(lockout_seconds)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"lockout_seconds must be a number, got {lockout_seconds!r}"
|
||||
) from err
|
||||
if lockout < 0:
|
||||
raise ValueError(
|
||||
f"lockout_seconds must be >= 0, got {lockout_seconds!r}"
|
||||
)
|
||||
self.max_requests = ceiling
|
||||
self.window_seconds = window
|
||||
self.lockout_seconds = lockout
|
||||
# (scope, identity) -> (window_start, count)
|
||||
self._windows: Dict[Tuple[str, str], Tuple[float, int]] = {}
|
||||
# (scope, identity) -> lockout_expires_at
|
||||
self._lockouts: Dict[Tuple[str, str], float] = {}
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether limiting is active (a positive ceiling is set)."""
|
||||
return self.max_requests > 0
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
now: float,
|
||||
) -> RateLimitDecision:
|
||||
# Disabled: preserve legacy always-allow behaviour.
|
||||
if self.max_requests <= 0:
|
||||
return RateLimitDecision(allowed=True)
|
||||
|
||||
key = (scope, identity)
|
||||
|
||||
# Active lockout?
|
||||
lockout_until = self._lockouts.get(key)
|
||||
if lockout_until is not None:
|
||||
if now < lockout_until:
|
||||
return RateLimitDecision(
|
||||
allowed=False,
|
||||
retry_after_seconds=max(0.0, lockout_until - now),
|
||||
)
|
||||
# Expired lockout: clear and start fresh.
|
||||
del self._lockouts[key]
|
||||
|
||||
window = self._windows.get(key)
|
||||
if window is None or (now - window[0]) >= self.window_seconds:
|
||||
# New or expired window.
|
||||
self._windows[key] = (now, 1)
|
||||
return RateLimitDecision(allowed=True)
|
||||
|
||||
window_start, count = window
|
||||
count += 1
|
||||
if count > self.max_requests:
|
||||
# Over the ceiling within the window.
|
||||
if self.lockout_seconds > 0:
|
||||
# Cooldown: drop the window and lock the key out until it
|
||||
# elapses.
|
||||
del self._windows[key]
|
||||
retry = self.lockout_seconds
|
||||
self._lockouts[key] = now + self.lockout_seconds
|
||||
else:
|
||||
# No cooldown: keep the window so the key stays denied until
|
||||
# it naturally expires, matching the retry hint. Deleting it
|
||||
# here would let the next check start a fresh window and be
|
||||
# allowed immediately, bypassing the ceiling.
|
||||
self._windows[key] = (window_start, count)
|
||||
retry = max(0.0, self.window_seconds - (now - window_start))
|
||||
return RateLimitDecision(
|
||||
allowed=False,
|
||||
retry_after_seconds=retry,
|
||||
)
|
||||
|
||||
self._windows[key] = (window_start, count)
|
||||
return RateLimitDecision(allowed=True)
|
||||
|
||||
|
||||
# Backward-compatible alias following the repo's ``*Protocol`` convention.
|
||||
RateLimitPolicy = RateLimitPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
#
|
||||
@@ -2809,3 +3029,546 @@ class ResumeSnapshot(TypedDict, total=False):
|
||||
presence: List[Dict[str, Any]] # Current presence information
|
||||
health: Dict[str, Any] # Gateway health status
|
||||
session_state: Dict[str, Any] # Session-specific state
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Relay transport (Issue #2485)
|
||||
#
|
||||
# A protocol-first seam so a thin *connector* process can own the platform
|
||||
# socket (Telegram/Discord/WhatsApp/...) and relay normalised inbound events
|
||||
# to a gateway over an authenticated transport, while accepting outbound
|
||||
# sends/interrupts back down. This decouples the *platform connection* from
|
||||
# the gateway process, enabling:
|
||||
# * headless / NAT-friendly hosting (gateway needs no public inbound port),
|
||||
# * one gateway fronting many remotely-hosted connectors,
|
||||
# * lossless scale-to-zero (the connector stays connected and buffers while
|
||||
# the gateway is dormant, draining the backlog on wake).
|
||||
#
|
||||
# These are *protocols only* — no transport (WebSocket/gRPC/message bus) and
|
||||
# no platform SDK is imported here. Concrete implementations live in the
|
||||
# praisonai wrapper.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityDescriptor:
|
||||
"""A capability profile a relay connector attests at handshake time.
|
||||
|
||||
Where :class:`~praisonaiagents.bots.presentation.PlatformCapabilities`
|
||||
declares capabilities *statically* in core, this descriptor is negotiated
|
||||
by a remote connector at connect time so the streaming/delivery layer can
|
||||
adapt to the actual platform the connector is fronting.
|
||||
|
||||
Attributes:
|
||||
max_message_length: Maximum outbound message length the platform
|
||||
accepts before the connector must split/truncate.
|
||||
length_unit: How ``max_message_length`` is measured — ``"chars"``
|
||||
(Unicode code points) or ``"utf16"`` (UTF-16 code units, as some
|
||||
platforms count).
|
||||
supports_edit: Whether the platform supports editing a sent message
|
||||
(enables draft-streaming via in-place edits).
|
||||
supports_draft_streaming: Whether the connector can stream partial
|
||||
drafts (incremental updates) for a single turn.
|
||||
markdown_dialect: Markdown flavour the platform renders
|
||||
(e.g. ``"none"``, ``"markdown"``, ``"markdownv2"``, ``"html"``).
|
||||
"""
|
||||
|
||||
max_message_length: int
|
||||
length_unit: str = "chars" # "chars" | "utf16"
|
||||
supports_edit: bool = False
|
||||
supports_draft_streaming: bool = False
|
||||
markdown_dialect: str = "none"
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary (for the handshake wire)."""
|
||||
return {
|
||||
"max_message_length": self.max_message_length,
|
||||
"length_unit": self.length_unit,
|
||||
"supports_edit": self.supports_edit,
|
||||
"supports_draft_streaming": self.supports_draft_streaming,
|
||||
"markdown_dialect": self.markdown_dialect,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "CapabilityDescriptor":
|
||||
"""Reconstruct a descriptor from its serialized form."""
|
||||
return cls(
|
||||
max_message_length=int(data["max_message_length"]),
|
||||
length_unit=str(data.get("length_unit", "chars")),
|
||||
supports_edit=bool(data.get("supports_edit", False)),
|
||||
supports_draft_streaming=bool(
|
||||
data.get("supports_draft_streaming", False)
|
||||
),
|
||||
markdown_dialect=str(data.get("markdown_dialect", "none")),
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RelayTransport(Protocol):
|
||||
"""Protocol for an out-of-process platform-connector relay.
|
||||
|
||||
A concrete implementation (e.g. a ``WebSocketRelayTransport`` in the
|
||||
praisonai wrapper) lets a connector that holds the platform socket
|
||||
forward normalised inbound :class:`GatewayMessage` events to the gateway
|
||||
and accept outbound sends/interrupts back down. The gateway treats the
|
||||
relay like any other adapter (same inbound routing, admission control,
|
||||
delivery) but the connection lives elsewhere.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
caps = await transport.connect() # handshake → capabilities
|
||||
transport.set_inbound_handler(on_message) # wire inbound events in
|
||||
... # events relayed in/out
|
||||
await transport.go_dormant() # pause, keep connection
|
||||
await transport.disconnect() # tear down
|
||||
"""
|
||||
|
||||
async def connect(self) -> "CapabilityDescriptor":
|
||||
"""Establish the relay and complete the handshake.
|
||||
|
||||
Returns the :class:`CapabilityDescriptor` attested by the connector
|
||||
for the platform it is fronting.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_inbound_handler(
|
||||
self, handler: Callable[["GatewayMessage"], Awaitable[None]]
|
||||
) -> None:
|
||||
"""Register the coroutine invoked for each relayed inbound message."""
|
||||
...
|
||||
|
||||
async def send_outbound(
|
||||
self, target: "TargetInfo", message: "GatewayMessage"
|
||||
) -> "DeliveryResult":
|
||||
"""Relay an outbound message to ``target`` via the connector."""
|
||||
...
|
||||
|
||||
async def go_dormant(self) -> None:
|
||||
"""Pause inbound dispatch without dropping the connection.
|
||||
|
||||
The connector keeps the platform socket open and buffers inbound
|
||||
events while the gateway is dormant (scale-to-zero), so they can be
|
||||
drained losslessly on wake.
|
||||
"""
|
||||
...
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Tear down the relay connection."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway pipeline span-tracing seam (Issue #2716)
|
||||
#
|
||||
# Running a bot fleet on the gateway means debugging latency/failures across an
|
||||
# async pipeline: inbound -> admission/queue -> agent turn -> each tool/LLM
|
||||
# call -> outbox -> delivery. Today an operator can correlate *logs* by a
|
||||
# single correlation id and read *counters* from ``/metrics``, but cannot see a
|
||||
# per-turn span breakdown or error spans in a distributed tracer
|
||||
# (Jaeger/Tempo/Datadog/Honeycomb).
|
||||
#
|
||||
# This is the missing *stage seam*: a dependency-free hook the wrapper gateway
|
||||
# can fire around each pipeline stage. Core holds only the protocol and a
|
||||
# zero-cost no-op default (``NullGatewayTraceHook``) so there is no OTel import
|
||||
# and no hot-path overhead when tracing is disabled. A ``praisonai-plugins``
|
||||
# OTel exporter implements the protocol and opens/closes real spans over OTLP,
|
||||
# reusing the existing correlation id as a span attribute — keeping the heavy
|
||||
# ``opentelemetry-sdk`` dependency out of core and the wrapper.
|
||||
#
|
||||
# The seam is intentionally a synchronous context-manager factory so it wraps
|
||||
# both sync and async stages uniformly (``with self._trace.stage(...):``) and
|
||||
# is trivially provable in isolation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canonical gateway pipeline stage names, so a tracer plugin and the wrapper
|
||||
# agree on span names without a hard import between them.
|
||||
GATEWAY_TRACE_STAGES = (
|
||||
"inbound",
|
||||
"admit",
|
||||
"agent.run",
|
||||
"llm.call",
|
||||
"tool.call",
|
||||
"outbox.enqueue",
|
||||
"delivery",
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GatewayTraceHook(Protocol):
|
||||
"""Structural contract for tracing a gateway pipeline stage as a span.
|
||||
|
||||
A hook is fired around each stage of the inbound -> agent -> tool ->
|
||||
delivery pipeline. Implementations return a context manager whose scope is
|
||||
the span: entering starts it, exiting ends it, and an exception propagating
|
||||
out marks the span as failed. The default core implementation
|
||||
(:class:`NullGatewayTraceHook`) is a no-op so tracing is zero-cost when no
|
||||
exporter is attached.
|
||||
|
||||
The contract is deliberately dependency-free: no OpenTelemetry import lives
|
||||
in core. A ``praisonai-plugins`` exporter implements ``stage`` with
|
||||
``tracer.start_as_current_span(...)`` and carries the correlation id as a
|
||||
span attribute. Example::
|
||||
|
||||
with self._trace.stage(
|
||||
"agent.run",
|
||||
correlation_id=current_correlation_id(),
|
||||
session=sid,
|
||||
):
|
||||
reply = await agent.astart(text)
|
||||
"""
|
||||
|
||||
def stage(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
correlation_id: "Optional[str]" = None,
|
||||
**attrs: Any,
|
||||
) -> "AbstractContextManager[Any]":
|
||||
"""Open a tracing scope for pipeline stage ``name``.
|
||||
|
||||
Args:
|
||||
name: The stage name (see :data:`GATEWAY_TRACE_STAGES`), used as the
|
||||
span name.
|
||||
correlation_id: The inbound turn's correlation id, attached as a
|
||||
span attribute so spans and logs share a key.
|
||||
**attrs: Extra span attributes (e.g. ``session``, ``model``,
|
||||
``tool``, ``channel``).
|
||||
|
||||
Returns:
|
||||
A context manager delimiting the span's lifetime.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class NullGatewayTraceHook:
|
||||
"""Zero-cost no-op :class:`GatewayTraceHook` used when tracing is disabled.
|
||||
|
||||
Every stage call returns a lightweight, argument-ignoring null context
|
||||
manager (one small allocation per call), so firing the seam adds negligible
|
||||
overhead on the hot path. This is the default a gateway uses until an
|
||||
exporter plugin (e.g. the OTel/OTLP plugin in ``praisonai-plugins``) is
|
||||
supplied.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def _null_scope() -> "Iterator[None]":
|
||||
yield None
|
||||
|
||||
def stage(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
correlation_id: "Optional[str]" = None,
|
||||
**attrs: Any,
|
||||
) -> "AbstractContextManager[Any]":
|
||||
"""Return a no-op context manager, ignoring all arguments."""
|
||||
return self._null_scope()
|
||||
|
||||
|
||||
# Shared singleton: the default no-op hook is stateless, so one instance is
|
||||
# reused everywhere a gateway needs a zero-cost default.
|
||||
NULL_GATEWAY_TRACE_HOOK = NullGatewayTraceHook()
|
||||
|
||||
|
||||
def resolve_trace_hook(
|
||||
hook: "Optional[GatewayTraceHook]",
|
||||
) -> "GatewayTraceHook":
|
||||
"""Return ``hook`` when a tracer is supplied, else the no-op default.
|
||||
|
||||
A tiny helper so a gateway can accept an optional ``tracer=`` argument and
|
||||
always hold a callable hook without branching on ``None`` at every stage.
|
||||
|
||||
Args:
|
||||
hook: A user/plugin-supplied trace hook, or ``None``.
|
||||
|
||||
Returns:
|
||||
``hook`` if not ``None``, otherwise the shared
|
||||
:data:`NULL_GATEWAY_TRACE_HOOK`.
|
||||
"""
|
||||
return hook if hook is not None else NULL_GATEWAY_TRACE_HOOK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway self-lifecycle command guardrail (Issue #2753)
|
||||
#
|
||||
# A running gateway is a long-lived process that hosts an agent which can act on
|
||||
# the same host — it can call a shell tool or author a scheduled job. Nothing
|
||||
# inspects the *content* of an agent-issued command to stop it from targeting
|
||||
# the gateway's own lifecycle: ``praisonai gateway stop``, ``pkill -f
|
||||
# 'praisonai gateway'``, or ``systemctl --user stop`` the gateway unit would
|
||||
# take the process itself down (self-DoS), and under an external supervisor can
|
||||
# become a respawn flap. Approval gating does not help — it decides *whether*
|
||||
# an agent may run a tool, not *whether a specific command is self-destructive*.
|
||||
#
|
||||
# This is the pure, import-free decision seam for a default-deny lifecycle
|
||||
# guardrail, symmetric with the other gateway policy protocols above
|
||||
# (``SendPolicy``, ``ScaleToZeroPolicy``, ``DrainMarkerPolicy``, …). The wrapper
|
||||
# consults it *before* a shell/CLI tool executes and *before* a scheduled job
|
||||
# is registered; a denied command returns a clean, model-readable reason rather
|
||||
# than running. Matching is command-anchored (structural token inspection, not
|
||||
# prose) so ordinary English mentioning "stop the gateway" is never tripped,
|
||||
# and it scans the command string (and any resolved script text). It is
|
||||
# on-by-default and opt-outable via ``enabled=False``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleCommandDecision:
|
||||
"""Closed decision shape for a self-lifecycle command evaluation.
|
||||
|
||||
Attributes:
|
||||
allow: Whether the command may run (``True``) or is refused
|
||||
(``False``) because it targets this gateway's own lifecycle.
|
||||
reason: Model-readable explanation, populated on denial so the caller
|
||||
can surface *why* the command was blocked.
|
||||
matched: The specific command fragment that triggered the deny (for
|
||||
logging / the blocked-attempt audit line); empty when allowed.
|
||||
"""
|
||||
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
matched: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LifecycleCommandPolicyProtocol(Protocol):
|
||||
"""Protocol for guarding an agent from stopping its own gateway.
|
||||
|
||||
Pure, import-free decision contract consulted by the wrapper *before* a
|
||||
shell/CLI tool executes and *before* a scheduled job is registered. The
|
||||
wrapper supplies the resolved command string (and, when a script file is
|
||||
involved, its text); the policy returns a :class:`LifecycleCommandDecision`
|
||||
that either allows the command or refuses it because its effect is to
|
||||
stop / restart / reload / kill *this* gateway process. Concrete pattern
|
||||
sets are swappable (a plugin may supply a richer one); this contract keeps
|
||||
the *decision* testable in isolation, symmetric with
|
||||
:class:`SendPolicyProtocol` / :class:`GatewayConcurrencyPolicyProtocol`.
|
||||
|
||||
A config-driven default (:class:`LifecycleCommandGuardPolicy`) is provided
|
||||
for the common ``praisonai gateway stop`` / ``pkill … gateway`` /
|
||||
``systemctl stop <unit>`` case.
|
||||
"""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
) -> LifecycleCommandDecision:
|
||||
"""Return a :class:`LifecycleCommandDecision` for ``command``."""
|
||||
...
|
||||
|
||||
|
||||
class LifecycleCommandGuardPolicy:
|
||||
"""Config-driven, command-anchored guard against gateway self-lifecycle hits.
|
||||
|
||||
The default referenced by ``gateway.lifecycle_guard`` blocks in
|
||||
``gateway.yaml`` and the ``BotOS(..., lifecycle_policy=...)`` Python
|
||||
surface. It is intentionally minimal and dependency-free so the decision
|
||||
lives in core and is provable in isolation; the wrapper owns the consult
|
||||
points (shell-tool executor, scheduler job registration) and the audit log.
|
||||
|
||||
Matching is *structural*, not prose-based, to avoid false positives on
|
||||
ordinary English (e.g. "please stop the gateway from spamming"):
|
||||
|
||||
* ``praisonai gateway stop|restart|reload`` — the CLI self-control verbs,
|
||||
matched only when ``praisonai`` and ``gateway`` appear as adjacent
|
||||
command tokens followed by a lifecycle verb.
|
||||
* ``pkill`` / ``kill`` / ``killall`` naming the gateway (a configured
|
||||
``process_names`` token — ``praisonai`` by default — appearing as a whole
|
||||
component in the argument list or a ``-f`` pattern).
|
||||
* ``systemctl`` / ``launchctl`` / ``sc`` ``stop`` / ``restart`` / ``kill``
|
||||
on a unit whose name mentions the gateway (same whole-component match).
|
||||
|
||||
Matching is *whole-component*, not bare substring: unrelated services whose
|
||||
name merely contains a token (``api-gateway``, ``kong-gateway``) are not
|
||||
tripped, while the real ``praisonai-gateway`` unit still matches.
|
||||
|
||||
The scan is applied to every ``;``/``&&``/``||``/pipe-separated segment of
|
||||
the command *and* to any additional script text supplied, so a command that
|
||||
shells out to a wrapper script cannot smuggle the intent past the guard.
|
||||
|
||||
``default_allow`` is a fail posture: on any internal parsing error the guard
|
||||
keeps today's behaviour (allow) unless ``default_allow=False`` is set for a
|
||||
strict, fail-closed deployment. ``enabled=False`` disables the guard
|
||||
entirely (an operator who legitimately wants an agent to manage the
|
||||
process).
|
||||
|
||||
Example::
|
||||
|
||||
LifecycleCommandGuardPolicy() # on by default
|
||||
LifecycleCommandGuardPolicy(enabled=False) # opt out
|
||||
LifecycleCommandGuardPolicy(process_names=["praisonai", "mybot"])
|
||||
"""
|
||||
|
||||
_CLI_VERBS = frozenset({"stop", "restart", "reload", "kill", "down"})
|
||||
_SERVICE_MGRS = frozenset({"systemctl", "launchctl", "service", "sc"})
|
||||
_SERVICE_VERBS = frozenset(
|
||||
{"stop", "restart", "reload", "kill", "disable", "down"}
|
||||
)
|
||||
_KILL_CMDS = frozenset({"pkill", "kill", "killall"})
|
||||
_SEGMENT_SPLIT = ("&&", "||", "|", ";", "\n")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool = True,
|
||||
process_names: Optional[List[str]] = None,
|
||||
default_allow: bool = True,
|
||||
):
|
||||
self.enabled = bool(enabled)
|
||||
# Default to the project's own, *specific* identity token. Bare
|
||||
# ``"gateway"`` is deliberately NOT a default deny token — it is too
|
||||
# generic and would false-positive on unrelated services whose name
|
||||
# contains it (``api-gateway``, ``kong-gateway``). The CLI-form rule
|
||||
# (``praisonai gateway stop``) still catches the self-control verbs via
|
||||
# the adjacent ``gateway`` sub-command, which needs no per-name entry.
|
||||
names = process_names if process_names else ["praisonai"]
|
||||
# Lower-cased, de-duplicated identity tokens that name *this* gateway.
|
||||
self.process_names = [str(n).strip().lower() for n in names if str(n).strip()]
|
||||
self.default_allow = bool(default_allow)
|
||||
|
||||
def _mentions_self(self, text: str) -> bool:
|
||||
"""Whether ``text`` names this gateway process/unit.
|
||||
|
||||
Uses *whole-component* matching, not bare substring containment, so an
|
||||
unrelated service whose name merely *contains* a configured token is
|
||||
not tripped. The text is split into identifier components on whitespace
|
||||
and the ``- _ . / : @``, ``'`` / ``"`` characters that delimit service
|
||||
units, paths and quoted ``-f`` patterns; a configured name matches only
|
||||
when it equals one of those components. Thus the default
|
||||
``process_names=["praisonai"]`` matches ``praisonai-gateway`` (real
|
||||
unit) and ``pkill -f praisonai`` but NOT ``api-gateway`` /
|
||||
``kong-gateway`` / ``my-praisonaibot``.
|
||||
"""
|
||||
import re
|
||||
|
||||
components = {c for c in re.split(r"[\s\-_./:@'\"]+", text) if c}
|
||||
return any(name in components for name in self.process_names)
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(segment: str) -> List[str]:
|
||||
"""Best-effort shell tokenization; falls back to whitespace split."""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
return shlex.split(segment)
|
||||
except ValueError:
|
||||
return segment.split()
|
||||
|
||||
def _segment_targets_self(self, segment: str) -> str:
|
||||
"""Return the offending fragment if ``segment`` hits our lifecycle.
|
||||
|
||||
Returns an empty string when the segment is benign.
|
||||
"""
|
||||
lowered = segment.lower()
|
||||
tokens = [t.lower() for t in self._tokenize(segment)]
|
||||
if not tokens:
|
||||
return ""
|
||||
# Drop common leading privilege/env prefixes so the real command verb
|
||||
# (e.g. ``sudo praisonai gateway stop``) is inspected structurally.
|
||||
idx = 0
|
||||
while idx < len(tokens) and tokens[idx] in ("sudo", "env", "nohup", "exec"):
|
||||
idx += 1
|
||||
head = tokens[idx:]
|
||||
if not head:
|
||||
return ""
|
||||
|
||||
cmd = head[0]
|
||||
# Normalise a path-qualified executable (``/usr/bin/pkill``) to its base.
|
||||
base = cmd.rsplit("/", 1)[-1]
|
||||
|
||||
# 1) <cli> gateway <verb> — the CLI self-control form. Enforce the
|
||||
# documented adjacency (``praisonai gateway stop``) rather than a
|
||||
# loose "words appear somewhere" match, and honour ``process_names``
|
||||
# so a renamed/forked CLI (``mybot gateway stop`` with
|
||||
# ``process_names=["mybot"]``) is covered symmetrically with rules 2/3.
|
||||
cli_names = set(self.process_names) | {"praisonai"}
|
||||
for gi, token in enumerate(head):
|
||||
if token != "gateway":
|
||||
continue
|
||||
prev_is_cli = gi >= 1 and head[gi - 1] in cli_names
|
||||
next_is_verb = gi + 1 < len(head) and head[gi + 1] in self._CLI_VERBS
|
||||
if prev_is_cli and next_is_verb:
|
||||
return segment.strip()
|
||||
|
||||
# 2) kill / pkill / killall naming the gateway
|
||||
if base in self._KILL_CMDS:
|
||||
args = head[1:]
|
||||
# -f/-fl pattern match, or an explicit process-name argument.
|
||||
if self._mentions_self(" ".join(args)):
|
||||
return segment.strip()
|
||||
|
||||
# 3) service manager stop/restart on our unit
|
||||
if base in self._SERVICE_MGRS:
|
||||
args = head[1:]
|
||||
if any(v in self._SERVICE_VERBS for v in args) and self._mentions_self(
|
||||
lowered
|
||||
):
|
||||
return segment.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
) -> LifecycleCommandDecision:
|
||||
"""Return a :class:`LifecycleCommandDecision` for ``command``.
|
||||
|
||||
Args:
|
||||
command: The resolved command / scheduled-job command string (may
|
||||
also carry appended script text — every segment is scanned).
|
||||
agent_id: Optional agent identity (accepted for parity with the
|
||||
other policy protocols; unused by the default guard).
|
||||
|
||||
Returns:
|
||||
An *allow* decision when the command is benign, or a *deny*
|
||||
decision naming the offending fragment when it would stop / restart
|
||||
/ kill this gateway.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return LifecycleCommandDecision(allow=True)
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
return LifecycleCommandDecision(allow=True)
|
||||
|
||||
try:
|
||||
segments: List[str] = [command]
|
||||
for sep in self._SEGMENT_SPLIT:
|
||||
expanded: List[str] = []
|
||||
for seg in segments:
|
||||
expanded.extend(seg.split(sep))
|
||||
segments = expanded
|
||||
|
||||
for seg in segments:
|
||||
offending = self._segment_targets_self(seg)
|
||||
if offending:
|
||||
return LifecycleCommandDecision(
|
||||
allow=False,
|
||||
reason=(
|
||||
"Refusing: command would stop/restart/kill this "
|
||||
"gateway process (self-lifecycle guard)"
|
||||
),
|
||||
matched=offending,
|
||||
)
|
||||
except Exception:
|
||||
# Parsing must never crash the caller: honour the configured fail
|
||||
# posture (fail-open by default, fail-closed when default_allow is
|
||||
# False for a strict, hosted gateway).
|
||||
if not self.default_allow:
|
||||
return LifecycleCommandDecision(
|
||||
allow=False,
|
||||
reason=(
|
||||
"Refusing: could not prove command is safe for this "
|
||||
"gateway (self-lifecycle guard, fail-closed)"
|
||||
),
|
||||
matched=command.strip(),
|
||||
)
|
||||
return LifecycleCommandDecision(allow=True)
|
||||
|
||||
return LifecycleCommandDecision(allow=True)
|
||||
|
||||
|
||||
# Backward-compatible alias following the repo's ``*Protocol`` convention.
|
||||
LifecycleCommandPolicy = LifecycleCommandPolicyProtocol
|
||||
@@ -63,6 +63,7 @@ __all__ = [
|
||||
# Event-specific inputs
|
||||
"BeforeToolInput",
|
||||
"AfterToolInput",
|
||||
"BeforeToolDefinitionsInput",
|
||||
"BeforeAgentInput",
|
||||
"AfterAgentInput",
|
||||
"BeforeLLMInput",
|
||||
@@ -79,6 +80,7 @@ __all__ = [
|
||||
"GatewayStartInput",
|
||||
"GatewayStopInput",
|
||||
"ScheduleTriggerInput",
|
||||
"JobCompletedInput",
|
||||
# Middleware types
|
||||
"InvocationContext",
|
||||
"ModelRequest",
|
||||
@@ -130,6 +132,7 @@ _LAZY_GROUPS = {
|
||||
'events': {
|
||||
'BeforeToolInput': ('praisonaiagents.hooks.events', 'BeforeToolInput'),
|
||||
'AfterToolInput': ('praisonaiagents.hooks.events', 'AfterToolInput'),
|
||||
'BeforeToolDefinitionsInput': ('praisonaiagents.hooks.events', 'BeforeToolDefinitionsInput'),
|
||||
'BeforeAgentInput': ('praisonaiagents.hooks.events', 'BeforeAgentInput'),
|
||||
'AfterAgentInput': ('praisonaiagents.hooks.events', 'AfterAgentInput'),
|
||||
'BeforeLLMInput': ('praisonaiagents.hooks.events', 'BeforeLLMInput'),
|
||||
@@ -144,6 +147,7 @@ _LAZY_GROUPS = {
|
||||
'GatewayStartInput': ('praisonaiagents.hooks.events', 'GatewayStartInput'),
|
||||
'GatewayStopInput': ('praisonaiagents.hooks.events', 'GatewayStopInput'),
|
||||
'ScheduleTriggerInput': ('praisonaiagents.hooks.events', 'ScheduleTriggerInput'),
|
||||
'JobCompletedInput': ('praisonaiagents.hooks.events', 'JobCompletedInput'),
|
||||
},
|
||||
'middleware_types': {
|
||||
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
|
||||
|
||||
@@ -47,6 +47,29 @@ class AfterToolInput(HookInput):
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeToolDefinitionsInput(HookInput):
|
||||
"""Input for BeforeToolDefinitions hooks.
|
||||
|
||||
Fired right after the advertised tool definitions are assembled for a
|
||||
request and before they reach the LLM. Hooks should modify or filter the
|
||||
``tool_definitions`` list in place (e.g. ``data.tool_definitions[:] = ...``)
|
||||
to redact parameters, append usage guidance to descriptions, or constrain
|
||||
the advertised schema per request. This mirrors how BEFORE_LLM mutates its
|
||||
payload in place; only in-place mutations are adopted by the runtime.
|
||||
"""
|
||||
tool_definitions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
model: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"tool_definitions_count": len(self.tool_definitions),
|
||||
"model": self.model,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeAgentInput(HookInput):
|
||||
"""Input for BeforeAgent hooks."""
|
||||
@@ -325,3 +348,37 @@ class ScheduleTriggerInput(HookInput):
|
||||
"message": self.message[:500] if self.message else "",
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobCompletedInput(HookInput):
|
||||
"""Input for JOB_COMPLETED hooks (a background job finished).
|
||||
|
||||
Emitted when a background subagent/job launched via
|
||||
``spawn_subagent(background=True)`` reaches a terminal state
|
||||
(completed or failed). Carries the optional origin/delivery context
|
||||
captured at spawn time so a gateway can route the result back to the
|
||||
originating conversation without an active turn.
|
||||
"""
|
||||
job_id: str = ""
|
||||
status: str = ""
|
||||
result: Any = None
|
||||
error: Optional[str] = None
|
||||
deliver: str = ""
|
||||
platform: str = ""
|
||||
chat_id: str = ""
|
||||
thread_id: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"job_id": self.job_id,
|
||||
"status": self.status,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
"deliver": self.deliver,
|
||||
"platform": self.platform,
|
||||
"chat_id": self.chat_id,
|
||||
"thread_id": self.thread_id,
|
||||
})
|
||||
return base
|
||||
@@ -23,6 +23,7 @@ class HookEvent(str, Enum):
|
||||
# Tool lifecycle
|
||||
BEFORE_TOOL = "before_tool"
|
||||
AFTER_TOOL = "after_tool"
|
||||
BEFORE_TOOL_DEFINITIONS = "before_tool_definitions"
|
||||
|
||||
# Agent lifecycle
|
||||
BEFORE_AGENT = "before_agent"
|
||||
@@ -68,6 +69,9 @@ class HookEvent(str, Enum):
|
||||
SCHEDULE_REMOVE = "schedule_remove"
|
||||
SCHEDULE_TRIGGER = "schedule_trigger"
|
||||
|
||||
# Background job lifecycle
|
||||
JOB_COMPLETED = "job_completed" # A background job finished (ok or error)
|
||||
|
||||
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
|
||||
KANBAN_TASK_CREATED = "kanban_task_created"
|
||||
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
|
||||
|
||||
@@ -142,22 +142,6 @@ class AuthProfile:
|
||||
self.last_error = None
|
||||
self.last_error_time = None
|
||||
|
||||
@runtime_checkable
|
||||
class FailoverProtocol(Protocol):
|
||||
"""Protocol for failover management."""
|
||||
|
||||
def get_next_profile(self) -> Optional[AuthProfile]:
|
||||
"""Get the next available profile."""
|
||||
...
|
||||
|
||||
def mark_failure(self, profile: AuthProfile, error: str) -> None:
|
||||
"""Mark a profile as failed."""
|
||||
...
|
||||
|
||||
def mark_success(self, profile: AuthProfile) -> None:
|
||||
"""Mark a profile as successful."""
|
||||
...
|
||||
|
||||
@dataclass
|
||||
class FailoverConfig:
|
||||
"""Configuration for failover behavior.
|
||||
@@ -403,3 +387,27 @@ class FailoverManager:
|
||||
"""Reset all profiles to available status."""
|
||||
for profile in self._profiles:
|
||||
profile.reset()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FailoverProtocol(Protocol):
|
||||
"""Deprecated structural protocol for failover managers.
|
||||
|
||||
Retained only for backward compatibility with code that imports the deep
|
||||
path ``praisonaiagents.llm.failover.FailoverProtocol`` or uses it for
|
||||
``isinstance`` checks. ``FailoverManager`` already satisfies this shape.
|
||||
Prefer the public ``LLMFailoverProtocol`` (exported from
|
||||
``praisonaiagents.llm``) for new code.
|
||||
"""
|
||||
|
||||
def get_next_profile(self) -> Optional[AuthProfile]:
|
||||
"""Get the next available profile."""
|
||||
...
|
||||
|
||||
def mark_failure(self, profile: AuthProfile, error: str) -> None:
|
||||
"""Mark a profile as failed."""
|
||||
...
|
||||
|
||||
def mark_success(self, profile: AuthProfile) -> None:
|
||||
"""Mark a profile as successful."""
|
||||
...
|
||||
@@ -24,7 +24,7 @@ import xml.etree.ElementTree as ET
|
||||
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker
|
||||
# Gap 2: Tool call execution imports
|
||||
from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import annotation_to_json_schema, get_parameter_requirements
|
||||
from ..tools.schema import build_tool_definition
|
||||
# Display functions - lazy loaded to avoid importing rich at startup
|
||||
# These are only needed when output=verbose
|
||||
_display_module = None
|
||||
@@ -191,6 +191,15 @@ class LLM:
|
||||
# Ollama-specific prompt constants
|
||||
OLLAMA_TOOL_USAGE_PROMPT = "Please analyze the request and use the available tools to help answer the question. Start by identifying what information you need."
|
||||
OLLAMA_FINAL_ANSWER_PROMPT = "Based on the tool results above, please provide the final answer to the original question."
|
||||
|
||||
# Step-limit finalisation constants (used when the tool loop hits max_iter)
|
||||
STEP_LIMIT_WRAP_UP_PROMPT = (
|
||||
"You have reached the step limit for this task. Do not call any tools. "
|
||||
"Provide a final answer that summarises what you accomplished, what "
|
||||
"remains unfinished, and any suggested next steps, using the tool "
|
||||
"results already gathered."
|
||||
)
|
||||
STEP_LIMIT_FALLBACK_MESSAGE = "Reached the step limit before finishing this task."
|
||||
|
||||
# Force tool usage prompt template (used when model ignores tools)
|
||||
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
|
||||
@@ -277,6 +286,19 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# If litellm not installed, we'll handle it in __init__
|
||||
pass
|
||||
|
||||
def _should_log_config(self, verbose=None):
|
||||
"""Cheap predicate for whether debug config logging is enabled.
|
||||
|
||||
Lets callers skip building large debug-only argument dicts (including
|
||||
``str(kwargs)``) on the common non-debug path.
|
||||
|
||||
Args:
|
||||
verbose: Optional per-call verbose value; falls back to ``self.verbose``.
|
||||
"""
|
||||
if verbose is None:
|
||||
verbose = self.verbose if hasattr(self, 'verbose') else False
|
||||
return get_logger().getEffectiveLevel() == logging.DEBUG or (not isinstance(verbose, bool) and verbose >= 10)
|
||||
|
||||
def _log_llm_config(self, method_name: str, **config):
|
||||
"""Centralized debug logging for LLM configuration and parameters.
|
||||
|
||||
@@ -286,7 +308,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
"""
|
||||
# Check for debug logging - either global debug level OR explicit verbose mode
|
||||
verbose = config.get('verbose', self.verbose if hasattr(self, 'verbose') else False)
|
||||
should_log = get_logger().getEffectiveLevel() == logging.DEBUG or (not isinstance(verbose, bool) and verbose >= 10)
|
||||
should_log = self._should_log_config(verbose)
|
||||
|
||||
if should_log:
|
||||
# Mask sensitive information
|
||||
@@ -408,6 +430,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._claude_memory_tool = None # Lazy initialized
|
||||
self._console = None # Lazy load console when needed
|
||||
self.max_iter = max_iter or 20 # Default to 20 to match ExecutionConfig default
|
||||
# Structured stop reason for the last tool-execution loop:
|
||||
# "completed" | "max_steps" | "error". Lets callers distinguish a finished
|
||||
# task from one truncated by the step budget (see ExecutionConfig.max_steps).
|
||||
self._last_stop_reason = "completed"
|
||||
self._idle_timeout_breaker = IdleTimeoutBreaker() # Circuit breaker for idle timeouts
|
||||
self.chat_history = []
|
||||
self.verbose = extra_settings.get('verbose', True)
|
||||
@@ -1831,6 +1857,90 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
|
||||
return False, None, iteration_count
|
||||
|
||||
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
|
||||
temperature: float = 0.2, **kwargs) -> str:
|
||||
"""
|
||||
Force a final tools-disabled answer when the tool-calling loop hits max_iter.
|
||||
|
||||
Instead of returning a canned placeholder, this performs exactly one extra
|
||||
LLM call with tools disabled (tool_choice="none") and a short wrap-up nudge,
|
||||
asking the model to summarise what it accomplished, what remains unfinished,
|
||||
and any next steps using the accumulated tool results already in context.
|
||||
|
||||
Falls back to the prior placeholder text if the extra call fails, so behaviour
|
||||
never regresses.
|
||||
|
||||
Args:
|
||||
messages: Current message history (already contains tool results).
|
||||
response_text: Any text emitted on the final turn (used as fallback).
|
||||
temperature: Sampling temperature for the wrap-up call.
|
||||
|
||||
Returns:
|
||||
The synthesised final answer, or a safe fallback string.
|
||||
"""
|
||||
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
|
||||
try:
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
final_params = self._build_completion_params(
|
||||
messages=messages + [wrap_up],
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
tool_choice="none",
|
||||
**safe_kwargs
|
||||
)
|
||||
# Ensure no tools are advertised even if the builder injects
|
||||
# runtime tools (e.g. web_fetch, Claude memory); tool_choice="none"
|
||||
# with tools present is invalid/undesirable for the wrap-up call.
|
||||
# Drop the now-meaningless tool_choice too, as some providers reject
|
||||
# tool_choice when no tools are supplied.
|
||||
final_params.pop("tools", None)
|
||||
final_params.pop("tool_choice", None)
|
||||
# Route through the retry wrapper so this bounded final call gets the
|
||||
# same rate-limit/failover resilience as every other completion.
|
||||
resp = self._completion_with_retry(**final_params)
|
||||
summary = resp["choices"][0]["message"].get("content")
|
||||
if summary and summary.strip():
|
||||
return summary.strip()
|
||||
except Exception as e:
|
||||
logging.warning(f"Step-limit finalisation call failed, using fallback: {e}")
|
||||
|
||||
if response_text and response_text.strip():
|
||||
return response_text.strip()
|
||||
return self.STEP_LIMIT_FALLBACK_MESSAGE
|
||||
|
||||
async def _finalise_on_limit_async(self, messages: List[Dict], response_text: str,
|
||||
temperature: float = 0.2, **kwargs) -> str:
|
||||
"""Async counterpart of :meth:`_finalise_on_limit`."""
|
||||
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
|
||||
try:
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
final_params = self._build_completion_params(
|
||||
messages=messages + [wrap_up],
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
tool_choice="none",
|
||||
**safe_kwargs
|
||||
)
|
||||
# Ensure no tools are advertised even if the builder injects
|
||||
# runtime tools (e.g. web_fetch, Claude memory); tool_choice="none"
|
||||
# with tools present is invalid/undesirable for the wrap-up call.
|
||||
# Drop the now-meaningless tool_choice too, as some providers reject
|
||||
# tool_choice when no tools are supplied.
|
||||
final_params.pop("tools", None)
|
||||
final_params.pop("tool_choice", None)
|
||||
# Route through the retry wrapper so this bounded final call gets the
|
||||
# same rate-limit/failover resilience as every other completion.
|
||||
resp = await self._acompletion_with_retry(**final_params)
|
||||
summary = resp["choices"][0]["message"].get("content")
|
||||
if summary and summary.strip():
|
||||
return summary.strip()
|
||||
except Exception as e:
|
||||
logging.warning(f"Step-limit finalisation call failed, using fallback: {e}")
|
||||
|
||||
if response_text and response_text.strip():
|
||||
return response_text.strip()
|
||||
return self.STEP_LIMIT_FALLBACK_MESSAGE
|
||||
|
||||
def _needs_system_message_skip(self) -> bool:
|
||||
"""Check if this model requires skipping system messages"""
|
||||
if not self.model:
|
||||
@@ -2105,6 +2215,16 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
"""Enhanced get_response with all OpenAI-like features"""
|
||||
logging.debug(f"Getting response from {self.model}")
|
||||
|
||||
# G2: Cooperative cancellation - honour InterruptController between tool iterations
|
||||
# so /stop (and cancellation generally) halts mid-flight runs on every provider.
|
||||
cancel_token = kwargs.pop("cancel_token", None)
|
||||
|
||||
def _is_cancelled() -> bool:
|
||||
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
|
||||
|
||||
def _cancel_reason() -> str:
|
||||
return getattr(cancel_token, "reason", None) or "user"
|
||||
|
||||
# Variable to store final response for token usage extraction
|
||||
_final_llm_response = None
|
||||
|
||||
@@ -2117,54 +2237,56 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
token_usage = TokenUsage()
|
||||
return text, token_usage
|
||||
|
||||
# Log all self values when in debug mode
|
||||
self._log_llm_config(
|
||||
'LLM instance',
|
||||
model=self.model,
|
||||
timeout=self.timeout,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
n=self.n,
|
||||
max_tokens=self.max_tokens,
|
||||
presence_penalty=self.presence_penalty,
|
||||
frequency_penalty=self.frequency_penalty,
|
||||
logit_bias=self.logit_bias,
|
||||
response_format=self.response_format,
|
||||
seed=self.seed,
|
||||
logprobs=self.logprobs,
|
||||
top_logprobs=self.top_logprobs,
|
||||
api_version=self.api_version,
|
||||
stop_phrases=self.stop_phrases,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
reasoning_steps=self.reasoning_steps
|
||||
)
|
||||
|
||||
# Log the parameter values passed to get_response
|
||||
self._log_llm_config(
|
||||
'get_response parameters',
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=verbose,
|
||||
markdown=markdown,
|
||||
reflection=self_reflect,
|
||||
max_reflect=max_reflect,
|
||||
min_reflect=min_reflect,
|
||||
agent_name=agent_name,
|
||||
agent_role=agent_role,
|
||||
agent_tools=agent_tools,
|
||||
kwargs=str(kwargs)
|
||||
)
|
||||
# Log all self values when in debug mode (skip building dicts otherwise)
|
||||
if self._should_log_config(self.verbose):
|
||||
self._log_llm_config(
|
||||
'LLM instance',
|
||||
model=self.model,
|
||||
timeout=self.timeout,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
n=self.n,
|
||||
max_tokens=self.max_tokens,
|
||||
presence_penalty=self.presence_penalty,
|
||||
frequency_penalty=self.frequency_penalty,
|
||||
logit_bias=self.logit_bias,
|
||||
response_format=self.response_format,
|
||||
seed=self.seed,
|
||||
logprobs=self.logprobs,
|
||||
top_logprobs=self.top_logprobs,
|
||||
api_version=self.api_version,
|
||||
stop_phrases=self.stop_phrases,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
reasoning_steps=self.reasoning_steps
|
||||
)
|
||||
|
||||
# Log the parameter values passed to get_response (skip str(kwargs) otherwise)
|
||||
if self._should_log_config(verbose):
|
||||
self._log_llm_config(
|
||||
'get_response parameters',
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=verbose,
|
||||
markdown=markdown,
|
||||
reflection=self_reflect,
|
||||
max_reflect=max_reflect,
|
||||
min_reflect=min_reflect,
|
||||
agent_name=agent_name,
|
||||
agent_role=agent_role,
|
||||
agent_tools=agent_tools,
|
||||
kwargs=str(kwargs)
|
||||
)
|
||||
try:
|
||||
import litellm
|
||||
# This below **kwargs** is passed to .completion() directly. so reasoning_steps has to be popped. OR find alternate best way of handling this.
|
||||
@@ -2225,8 +2347,17 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
response_text = "" # Initialize to prevent UnboundLocalError on API errors
|
||||
stored_reasoning_content = None # Store reasoning content from tool execution
|
||||
accumulated_tool_results = [] # Store all tool results across iterations
|
||||
# Structured stop reason so callers can distinguish completion from
|
||||
# truncation (unified with the OpenAI-native path). "completed" default.
|
||||
self._last_stop_reason = "completed"
|
||||
|
||||
while iteration_count < max_iterations:
|
||||
# G2: Check for cancellation at the top of each tool iteration so a
|
||||
# /stop request promptly halts a mid-flight run on every provider.
|
||||
if _is_cancelled():
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"LLM tool loop cancelled: {reason}")
|
||||
return _prepare_return_value(f"Task interrupted: {reason}")
|
||||
try:
|
||||
# Get response from LiteLLM
|
||||
current_time = time.time()
|
||||
@@ -2402,7 +2533,11 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
|
||||
# Safety: break after max_iter iterations
|
||||
if iteration_count >= self.max_iter:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
self._last_stop_reason = "max_steps"
|
||||
final_response_text = self._finalise_on_limit(
|
||||
messages, response_text, temperature=temperature,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
)
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
@@ -2419,7 +2554,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# ── Chat Completions path (unchanged) ───────────────────
|
||||
# If reasoning_steps is True, do a single non-streaming call
|
||||
if reasoning_steps:
|
||||
resp = litellm.completion(
|
||||
resp = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -2571,7 +2706,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
if verbose:
|
||||
# Verbose streaming: show display_generating during streaming
|
||||
with _get_live()(_get_display_functions()['display_generating']("", current_time), console=self.console, refresh_per_second=4) as live:
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2590,7 +2725,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
live.update(_get_display_functions()['display_generating'](response_text, current_time))
|
||||
else:
|
||||
# Non-verbose streaming: no display_generating during streaming
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2621,7 +2756,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
# Use streaming when verbose for progressive display
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2653,7 +2788,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}
|
||||
else:
|
||||
# For non-streaming + non-verbose: no display_generating (per user requirements)
|
||||
final_response = litellm.completion(
|
||||
final_response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2772,7 +2907,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
# Use streaming when verbose for progressive display
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2819,7 +2954,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Silent streaming: no display, just accumulate
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -2845,7 +2980,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}
|
||||
else:
|
||||
# Standard non-streaming path
|
||||
final_response = litellm.completion(
|
||||
final_response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -3084,7 +3219,9 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
@@ -3224,10 +3361,11 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
|
||||
# Safety check: prevent infinite loops for any provider
|
||||
if iteration_count >= self.max_iter:
|
||||
if tool_results:
|
||||
final_response_text = "Task completed successfully based on tool execution results."
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
self._last_stop_reason = "max_steps"
|
||||
final_response_text = self._finalise_on_limit(
|
||||
messages, response_text, temperature=temperature,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
)
|
||||
break
|
||||
|
||||
# Otherwise, continue the loop to get final response with tool results
|
||||
@@ -3437,7 +3575,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# If reasoning_steps is True, do a single non-streaming call to capture reasoning
|
||||
if reasoning_steps:
|
||||
reflection_resp = litellm.completion(
|
||||
reflection_resp = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -3486,7 +3624,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if verbose:
|
||||
with _get_live()(_get_display_functions()['display_generating']("", start_time), console=self.console, refresh_per_second=4) as live:
|
||||
reflection_text = ""
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -3503,7 +3641,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
live.update(_get_display_functions()['display_generating'](reflection_text, start_time))
|
||||
else:
|
||||
reflection_text = ""
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -3557,7 +3695,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if verbose:
|
||||
with _get_live()(_get_display_functions()['display_generating']("", time.time()), console=self.console, refresh_per_second=4) as live:
|
||||
response_text = ""
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -3574,7 +3712,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
live.update(_get_display_functions()['display_generating'](response_text, time.time()))
|
||||
else:
|
||||
response_text = ""
|
||||
for chunk in litellm.completion(
|
||||
for chunk in self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -3705,7 +3843,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
consecutive_errors = 0
|
||||
max_consecutive_errors = 3 # Fallback to non-streaming after 3 consecutive errors
|
||||
|
||||
stream_iterator = litellm.completion(
|
||||
stream_iterator = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -3976,57 +4114,69 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Async version of get_response with identical functionality."""
|
||||
# G2: Cooperative cancellation - honour InterruptController between tool iterations
|
||||
# so /stop (and cancellation generally) halts mid-flight runs on every provider.
|
||||
cancel_token = kwargs.pop("cancel_token", None)
|
||||
|
||||
def _is_cancelled() -> bool:
|
||||
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
|
||||
|
||||
def _cancel_reason() -> str:
|
||||
return getattr(cancel_token, "reason", None) or "user"
|
||||
|
||||
try:
|
||||
import litellm
|
||||
logging.debug(f"Getting async response from {self.model}")
|
||||
# Log all self values when in debug mode
|
||||
self._log_llm_config(
|
||||
'get_response_async',
|
||||
model=self.model,
|
||||
timeout=self.timeout,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
n=self.n,
|
||||
max_tokens=self.max_tokens,
|
||||
presence_penalty=self.presence_penalty,
|
||||
frequency_penalty=self.frequency_penalty,
|
||||
logit_bias=self.logit_bias,
|
||||
response_format=self.response_format,
|
||||
seed=self.seed,
|
||||
logprobs=self.logprobs,
|
||||
top_logprobs=self.top_logprobs,
|
||||
api_version=self.api_version,
|
||||
stop_phrases=self.stop_phrases,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
reasoning_steps=self.reasoning_steps
|
||||
)
|
||||
|
||||
# Log the parameter values passed to get_response_async
|
||||
self._log_llm_config(
|
||||
'get_response_async parameters',
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=verbose,
|
||||
markdown=markdown,
|
||||
reflection=self_reflect,
|
||||
max_reflect=max_reflect,
|
||||
min_reflect=min_reflect,
|
||||
agent_name=agent_name,
|
||||
agent_role=agent_role,
|
||||
agent_tools=agent_tools,
|
||||
kwargs=str(kwargs)
|
||||
)
|
||||
# Log all self values when in debug mode (skip building dicts otherwise)
|
||||
if self._should_log_config(self.verbose):
|
||||
self._log_llm_config(
|
||||
'get_response_async',
|
||||
model=self.model,
|
||||
timeout=self.timeout,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
n=self.n,
|
||||
max_tokens=self.max_tokens,
|
||||
presence_penalty=self.presence_penalty,
|
||||
frequency_penalty=self.frequency_penalty,
|
||||
logit_bias=self.logit_bias,
|
||||
response_format=self.response_format,
|
||||
seed=self.seed,
|
||||
logprobs=self.logprobs,
|
||||
top_logprobs=self.top_logprobs,
|
||||
api_version=self.api_version,
|
||||
stop_phrases=self.stop_phrases,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
reasoning_steps=self.reasoning_steps
|
||||
)
|
||||
|
||||
# Log the parameter values passed to get_response_async (skip str(kwargs) otherwise)
|
||||
if self._should_log_config(verbose):
|
||||
self._log_llm_config(
|
||||
'get_response_async parameters',
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=verbose,
|
||||
markdown=markdown,
|
||||
reflection=self_reflect,
|
||||
max_reflect=max_reflect,
|
||||
min_reflect=min_reflect,
|
||||
agent_name=agent_name,
|
||||
agent_role=agent_role,
|
||||
agent_tools=agent_tools,
|
||||
kwargs=str(kwargs)
|
||||
)
|
||||
reasoning_steps = kwargs.pop('reasoning_steps', self.reasoning_steps)
|
||||
litellm.set_verbose = False
|
||||
|
||||
@@ -4054,8 +4204,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
final_response_text = ""
|
||||
stored_reasoning_content = None # Store reasoning content from tool execution
|
||||
accumulated_tool_results = [] # Store all tool results across iterations
|
||||
# Structured stop reason (unified with the OpenAI-native path).
|
||||
self._last_stop_reason = "completed"
|
||||
|
||||
while iteration_count < max_iterations:
|
||||
# G2: Check for cancellation at the top of each tool iteration so a
|
||||
# /stop request promptly halts a mid-flight run on every provider.
|
||||
if _is_cancelled():
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"Async LLM tool loop cancelled: {reason}")
|
||||
return f"Task interrupted: {reason}"
|
||||
response_text = ""
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
@@ -4127,7 +4285,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
@@ -4164,7 +4324,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
})
|
||||
|
||||
if iteration_count >= self.max_iter:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
self._last_stop_reason = "max_steps"
|
||||
final_response_text = await self._finalise_on_limit_async(
|
||||
messages, response_text, temperature=temperature,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
)
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
@@ -4176,7 +4340,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# ── Chat Completions path (unchanged) ───────────────
|
||||
if reasoning_steps and iteration_count == 0:
|
||||
# Non-streaming call to capture reasoning
|
||||
resp = await litellm.acompletion(
|
||||
resp = await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4235,7 +4399,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_calls = []
|
||||
|
||||
if verbose:
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4257,7 +4421,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
else:
|
||||
# Non-verbose streaming
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4283,7 +4447,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# No need for a second API call!
|
||||
else:
|
||||
# Non-streaming approach (when tools require it or streaming is disabled)
|
||||
tool_response = await litellm.acompletion(
|
||||
tool_response = await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4353,7 +4517,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
@@ -4434,7 +4600,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# If no special handling was needed
|
||||
if reasoning_steps:
|
||||
# Non-streaming call to capture reasoning
|
||||
resp = await litellm.acompletion(
|
||||
resp = await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4485,7 +4651,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not self._provider_adapter.supports_streaming():
|
||||
_use_stream = False
|
||||
if not _use_stream:
|
||||
resp = await litellm.acompletion(
|
||||
resp = await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4503,7 +4669,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if _next_tool_calls:
|
||||
tool_calls = _next_tool_calls
|
||||
elif verbose:
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4521,7 +4687,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
print(f"Reflecting... {time.time() - start_time:.1f}s", end="\r")
|
||||
else:
|
||||
response_text = ""
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -4573,10 +4739,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Safety check: prevent infinite loops for any provider
|
||||
if iteration_count >= self.max_iter:
|
||||
if tool_results:
|
||||
final_response_text = "Task completed successfully based on tool execution results."
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
self._last_stop_reason = "max_steps"
|
||||
final_response_text = await self._finalise_on_limit_async(
|
||||
messages, response_text, temperature=temperature,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
)
|
||||
break
|
||||
|
||||
# Continue the loop to check if more tools are needed
|
||||
@@ -4661,7 +4828,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# If reasoning_steps is True, do a single non-streaming call to capture reasoning
|
||||
if reasoning_steps:
|
||||
reflection_resp = await litellm.acompletion(
|
||||
reflection_resp = await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -4710,7 +4877,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if verbose:
|
||||
with _get_live()(_get_display_functions()['display_generating']("", start_time), console=self.console, refresh_per_second=4) as live:
|
||||
reflection_text = ""
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -4727,7 +4894,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
live.update(_get_display_functions()['display_generating'](reflection_text, start_time))
|
||||
else:
|
||||
reflection_text = ""
|
||||
async for chunk in await litellm.acompletion(
|
||||
async for chunk in await self._acompletion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
@@ -5785,7 +5952,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
if stream:
|
||||
with _get_live()(_get_display_functions()['display_generating']("", start_time), console=console or self.console, refresh_per_second=4) as live:
|
||||
for chunk in litellm.completion(**completion_params):
|
||||
for chunk in self._completion_with_retry(**completion_params):
|
||||
content = self._process_streaming_chunk(chunk)
|
||||
if content:
|
||||
response_text += content
|
||||
@@ -5880,7 +6047,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
if stream:
|
||||
with _get_live()(_get_display_functions()['display_generating']("", start_time), console=console or self.console, refresh_per_second=4) as live:
|
||||
async for chunk in await litellm.acompletion(**completion_params):
|
||||
async for chunk in await self._acompletion_with_retry(**completion_params):
|
||||
content = self._process_streaming_chunk(chunk)
|
||||
if content:
|
||||
response_text += content
|
||||
@@ -5988,86 +6155,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
|
||||
import inspect
|
||||
from typing import get_type_hints
|
||||
|
||||
# Handle Langchain and CrewAI tools
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
original_func = func
|
||||
func = func.run
|
||||
function_name = original_func.__name__
|
||||
elif inspect.isclass(func) and hasattr(func, '_run'):
|
||||
original_func = func
|
||||
func = func._run
|
||||
function_name = original_func.__name__
|
||||
|
||||
sig = inspect.signature(func)
|
||||
logging.debug(f"Function signature: {sig}")
|
||||
|
||||
# Skip self, *args, **kwargs
|
||||
parameters_list = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
parameters_list.append((name, param))
|
||||
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
# Parse docstring for parameter descriptions
|
||||
docstring = inspect.getdoc(func)
|
||||
logging.debug(f"Function docstring: {docstring}")
|
||||
|
||||
param_descriptions = {}
|
||||
if docstring:
|
||||
import re
|
||||
param_section = re.split(r'\s*Args:\s*', docstring)
|
||||
logging.debug(f"Param section split: {param_section}")
|
||||
if len(param_section) > 1:
|
||||
param_lines = param_section[1].split('\n')
|
||||
for line in param_lines:
|
||||
line = line.strip()
|
||||
if line and ':' in line:
|
||||
param_name, param_desc = line.split(':', 1)
|
||||
param_descriptions[param_name.strip()] = param_desc.strip()
|
||||
|
||||
logging.debug(f"Parameter descriptions: {param_descriptions}")
|
||||
|
||||
# Get type hints for proper schema generation
|
||||
try:
|
||||
hints = get_type_hints(func) if getattr(func, "__annotations__", None) else {}
|
||||
except (NameError, TypeError, AttributeError):
|
||||
hints = getattr(func, "__annotations__", {}) or {}
|
||||
|
||||
for name, param in parameters_list:
|
||||
# Get type annotation
|
||||
param_type = hints.get(name, param.annotation if param.annotation != inspect.Parameter.empty else str)
|
||||
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
# Add description from docstring
|
||||
prop_schema["description"] = param_descriptions.get(name, "Parameter description not available")
|
||||
|
||||
parameters["properties"][name] = prop_schema
|
||||
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, name):
|
||||
parameters["required"].append(name)
|
||||
|
||||
logging.debug(f"Generated parameters: {parameters}")
|
||||
tool_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_name,
|
||||
"description": docstring.split('\n\n')[0] if docstring else "No description available",
|
||||
"parameters": self._fix_array_schemas(parameters)
|
||||
}
|
||||
}
|
||||
# Delegate signature introspection + schema generation to the shared
|
||||
# core helper so all layers produce identical, provider-safe schemas
|
||||
# (including array 'items' normalisation via fix_array_schemas).
|
||||
from ..tools.schema import build_tool_definition
|
||||
tool_def = build_tool_definition(func, function_name)
|
||||
logging.debug(f"Generated tool definition: {tool_def}")
|
||||
return tool_def
|
||||
@@ -17,6 +17,15 @@ from pydantic import BaseModel
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
|
||||
# Graceful "wrap-up" instruction injected when the step budget is nearly
|
||||
# exhausted, so the model produces a coherent final answer instead of being
|
||||
# hard-cut. Shared by both tool-execution loops for consistent behaviour.
|
||||
_MAX_STEPS_WRAPUP_PROMPT = (
|
||||
"You are approaching the maximum number of tool-use steps for this task. "
|
||||
"Stop calling tools now and provide your best final answer, summarising the "
|
||||
"work completed so far and clearly noting anything left incomplete."
|
||||
)
|
||||
|
||||
# Lazy imports for optional dependencies
|
||||
_openai_module = None
|
||||
_rich_console = None
|
||||
@@ -285,17 +294,32 @@ class OpenAIClient:
|
||||
interface for chat completions, streaming, and structured outputs.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, max_retries: Optional[int] = None):
|
||||
"""
|
||||
Initialize the OpenAI client with proper API key handling.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key (defaults to OPENAI_API_KEY env var)
|
||||
base_url: Custom base URL for API endpoints (defaults to OPENAI_API_BASE env var)
|
||||
max_retries: Number of automatic retries for transient errors (rate limits,
|
||||
network blips) performed by the underlying OpenAI SDK, which honours
|
||||
``Retry-After`` and applies exponential backoff. Defaults to the
|
||||
``OPENAI_MAX_RETRIES`` env var when set, otherwise the SDK default
|
||||
(transparent, no behaviour change).
|
||||
"""
|
||||
# Use provided values or fall back to environment variables
|
||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
self.base_url = base_url or os.environ.get("OPENAI_API_BASE") or os.environ.get("OPENAI_BASE_URL")
|
||||
|
||||
# Resolve retry configuration: explicit arg wins, else OPENAI_MAX_RETRIES env var.
|
||||
if max_retries is None:
|
||||
_env_retries = os.environ.get("OPENAI_MAX_RETRIES")
|
||||
if _env_retries is not None:
|
||||
try:
|
||||
max_retries = int(_env_retries)
|
||||
except (TypeError, ValueError):
|
||||
max_retries = None
|
||||
self.max_retries = max_retries
|
||||
|
||||
# For local servers like LM Studio, allow minimal API key
|
||||
if self.base_url and not self.api_key:
|
||||
@@ -335,7 +359,10 @@ class OpenAIClient:
|
||||
"""Get the synchronous OpenAI client (lazy initialization)."""
|
||||
if self._sync_client is None:
|
||||
OpenAI, _ = _get_openai_classes()
|
||||
self._sync_client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
self._sync_client = OpenAI(**client_kwargs)
|
||||
return self._sync_client
|
||||
|
||||
@property
|
||||
@@ -343,7 +370,10 @@ class OpenAIClient:
|
||||
"""Get the asynchronous OpenAI client (lazy initialization)."""
|
||||
if self._async_client is None:
|
||||
_, AsyncOpenAI = _get_openai_classes()
|
||||
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
self._async_client = AsyncOpenAI(**client_kwargs)
|
||||
return self._async_client
|
||||
|
||||
def build_messages(
|
||||
@@ -770,76 +800,15 @@ class OpenAIClient:
|
||||
)
|
||||
|
||||
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
|
||||
"""Generate a tool definition from a callable function."""
|
||||
"""Generate a tool definition from a callable function.
|
||||
|
||||
Delegates to the shared core helper so tool schemas are identical across
|
||||
the agent, LLM and OpenAI-client layers (including array 'items'
|
||||
normalisation required by strict providers).
|
||||
"""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
|
||||
# Skip self, *args, **kwargs
|
||||
parameters_list = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
parameters_list.append((name, param))
|
||||
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
# Parse docstring for parameter descriptions
|
||||
docstring = inspect.getdoc(func)
|
||||
param_descriptions = {}
|
||||
if docstring:
|
||||
import re
|
||||
param_section = re.split(r'\s*Args:\s*', docstring)
|
||||
if len(param_section) > 1:
|
||||
param_lines = param_section[1].split('\n')
|
||||
for line in param_lines:
|
||||
line = line.strip()
|
||||
if line and ':' in line:
|
||||
param_name, param_desc = line.split(':', 1)
|
||||
param_descriptions[param_name.strip()] = param_desc.strip()
|
||||
|
||||
for name, param in parameters_list:
|
||||
param_type = "string" # Default type
|
||||
param_info = {}
|
||||
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation is int:
|
||||
param_type = "integer"
|
||||
elif param.annotation is float:
|
||||
param_type = "number"
|
||||
elif param.annotation is bool:
|
||||
param_type = "boolean"
|
||||
elif param.annotation is list:
|
||||
param_type = "array"
|
||||
# OpenAI requires 'items' for array types
|
||||
param_info["items"] = {"type": "string"}
|
||||
elif param.annotation is dict:
|
||||
param_type = "object"
|
||||
|
||||
param_info["type"] = param_type
|
||||
if name in param_descriptions:
|
||||
param_info["description"] = param_descriptions[name]
|
||||
|
||||
parameters["properties"][name] = param_info
|
||||
if param.default == inspect.Parameter.empty:
|
||||
parameters["required"].append(name)
|
||||
|
||||
# Extract description from docstring
|
||||
description = docstring.split('\n')[0] if docstring else f"Function {func.__name__}"
|
||||
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": func.__name__,
|
||||
"description": description,
|
||||
"parameters": parameters
|
||||
}
|
||||
}
|
||||
from ..tools.schema import build_tool_definition
|
||||
return build_tool_definition(func)
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating tool definition: {e}")
|
||||
return None
|
||||
@@ -1533,16 +1502,34 @@ class OpenAIClient:
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Work on a local copy so the tool-loop mutations (assistant/tool
|
||||
# turns and the graceful wrap-up control message) never leak back into
|
||||
# the caller's conversation history and pollute subsequent turns.
|
||||
messages = list(messages)
|
||||
|
||||
# Format tools for OpenAI API
|
||||
formatted_tools = self.format_tools(tools)
|
||||
|
||||
# Continue tool execution loop until no more tool calls are needed
|
||||
iteration_count = 0
|
||||
# Structured stop reason so callers can distinguish completion from
|
||||
# truncation (unified with the LiteLLM path). "completed" by default.
|
||||
self._last_stop_reason = "completed"
|
||||
_wrapup_injected = False
|
||||
|
||||
while iteration_count < max_iterations:
|
||||
# Trigger LLM callback for status/trace output
|
||||
from ..main import execute_sync_callback
|
||||
execute_sync_callback('llm_start', model=model, agent_name=None)
|
||||
|
||||
# Graceful wrap-up: on the final permitted step, ask the model to
|
||||
# produce a coherent final answer instead of being hard-cut.
|
||||
if not _wrapup_injected and max_iterations > 1 and iteration_count == max_iterations - 1:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _MAX_STEPS_WRAPUP_PROMPT,
|
||||
})
|
||||
_wrapup_injected = True
|
||||
|
||||
if stream:
|
||||
# Process as streaming response with formatted tools
|
||||
@@ -1737,6 +1724,10 @@ class OpenAIClient:
|
||||
# The model will see tool results and can make additional tool calls
|
||||
|
||||
iteration_count += 1
|
||||
# If we've exhausted the budget while the model still wants tools,
|
||||
# the task was truncated rather than completed.
|
||||
if iteration_count >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
else:
|
||||
# No tool calls, we're done
|
||||
break
|
||||
@@ -1784,13 +1775,29 @@ class OpenAIClient:
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Work on a local copy so the tool-loop mutations (assistant/tool
|
||||
# turns and the graceful wrap-up control message) never leak back into
|
||||
# the caller's conversation history and pollute subsequent turns.
|
||||
messages = list(messages)
|
||||
|
||||
# Format tools for OpenAI API
|
||||
formatted_tools = self.format_tools(tools)
|
||||
|
||||
# Continue tool execution loop until no more tool calls are needed
|
||||
iteration_count = 0
|
||||
# Structured stop reason (unified with the sync/LiteLLM paths).
|
||||
self._last_stop_reason = "completed"
|
||||
_wrapup_injected = False
|
||||
|
||||
while iteration_count < max_iterations:
|
||||
# Graceful wrap-up on the final permitted step.
|
||||
if not _wrapup_injected and max_iterations > 1 and iteration_count == max_iterations - 1:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _MAX_STEPS_WRAPUP_PROMPT,
|
||||
})
|
||||
_wrapup_injected = True
|
||||
|
||||
if stream:
|
||||
# Process as streaming response with formatted tools
|
||||
final_response = await self.process_stream_response_async(
|
||||
@@ -1927,11 +1934,16 @@ class OpenAIClient:
|
||||
if asyncio.iscoroutinefunction(execute_tool_fn):
|
||||
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
else:
|
||||
# Run sync function in executor
|
||||
# Run sync function in executor (preserve ContextVars e.g. SessionContext)
|
||||
loop = asyncio.get_running_loop()
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
tool_result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
|
||||
None,
|
||||
copy_context_to_callable(
|
||||
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(
|
||||
fn, args, tool_call_id=tcid
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
@@ -1958,6 +1970,8 @@ class OpenAIClient:
|
||||
# The model will see tool results and can make additional tool calls
|
||||
|
||||
iteration_count += 1
|
||||
if iteration_count >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
else:
|
||||
# No tool calls, we're done
|
||||
break
|
||||
@@ -2255,28 +2269,42 @@ _global_client = None
|
||||
_global_client_params = None
|
||||
_global_client_lock = threading.Lock()
|
||||
|
||||
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> OpenAIClient:
|
||||
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None, max_retries: Optional[int] = None) -> OpenAIClient:
|
||||
"""
|
||||
Get or create a global OpenAI client instance.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key (defaults to OPENAI_API_KEY env var)
|
||||
base_url: Custom base URL for API endpoints
|
||||
max_retries: Optional number of SDK-level automatic retries for transient
|
||||
errors (honours ``Retry-After`` and applies backoff). Defaults to the
|
||||
``OPENAI_MAX_RETRIES`` env var when unset, otherwise the SDK default.
|
||||
|
||||
Returns:
|
||||
OpenAIClient instance
|
||||
"""
|
||||
global _global_client, _global_client_params
|
||||
|
||||
# Normalize parameters for comparison
|
||||
normalized_api_key = api_key or os.getenv("OPENAI_API_KEY")
|
||||
normalized_base_url = base_url
|
||||
current_params = (normalized_api_key, normalized_base_url)
|
||||
# Normalize parameters for comparison, mirroring OpenAIClient.__init__ so that
|
||||
# an explicit argument matching the env-var fallback reuses the cached client.
|
||||
normalized_api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
normalized_base_url = (
|
||||
base_url or os.environ.get("OPENAI_API_BASE") or os.environ.get("OPENAI_BASE_URL")
|
||||
)
|
||||
normalized_max_retries = max_retries
|
||||
if normalized_max_retries is None:
|
||||
_env_retries = os.environ.get("OPENAI_MAX_RETRIES")
|
||||
if _env_retries is not None:
|
||||
try:
|
||||
normalized_max_retries = int(_env_retries)
|
||||
except (TypeError, ValueError):
|
||||
normalized_max_retries = None
|
||||
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries)
|
||||
|
||||
# Thread-safe client creation
|
||||
with _global_client_lock:
|
||||
# Only create new client if parameters changed or first time
|
||||
if _global_client is None or _global_client_params != current_params:
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url, max_retries=max_retries)
|
||||
_global_client_params = current_params
|
||||
return _global_client
|
||||
return _global_client
|
||||
@@ -3,61 +3,12 @@
|
||||
This module provides helpers for normalising tool/function schemas so they are
|
||||
compatible with OpenAI-style function calling across the LLM, OpenAI client and
|
||||
MCP code paths.
|
||||
|
||||
The canonical implementation of ``fix_array_schemas`` now lives in the core
|
||||
``praisonaiagents.tools.schema`` module; it is re-exported here so existing
|
||||
LLM/OpenAI-client/MCP imports continue to work unchanged.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from ..tools.schema import fix_array_schemas
|
||||
|
||||
|
||||
def fix_array_schemas(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively fix array schemas by adding missing 'items' attribute.
|
||||
|
||||
This is the single canonical implementation shared by the LLM, OpenAI client
|
||||
and MCP code paths. It ensures compatibility with OpenAI's function calling
|
||||
format which requires array types to specify the type of items they contain,
|
||||
and normalises nested ``properties``, ``items``, ``additionalProperties`` and
|
||||
``anyOf``/``oneOf``/``allOf`` schema constructs.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema (the original is not mutated)
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas in properties
|
||||
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
if isinstance(prop_schema, dict):
|
||||
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
|
||||
else:
|
||||
fixed_properties[prop_name] = prop_schema
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
|
||||
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
# Fix additionalProperties if it's a schema
|
||||
if isinstance(fixed_schema.get("additionalProperties"), dict):
|
||||
fixed_schema["additionalProperties"] = fix_array_schemas(
|
||||
fixed_schema["additionalProperties"]
|
||||
)
|
||||
|
||||
# Fix anyOf/oneOf/allOf schemas
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
if key in fixed_schema and isinstance(fixed_schema[key], list):
|
||||
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
|
||||
|
||||
return fixed_schema
|
||||
__all__ = ["fix_array_schemas"]
|
||||
@@ -113,7 +113,11 @@ class LSPClient:
|
||||
"hover": {},
|
||||
"definition": {},
|
||||
"references": {},
|
||||
"documentSymbol": {},
|
||||
"publishDiagnostics": {}
|
||||
},
|
||||
"workspace": {
|
||||
"symbol": {}
|
||||
}
|
||||
},
|
||||
"initializationOptions": self.config.initialization_options
|
||||
@@ -373,6 +377,54 @@ class LSPClient:
|
||||
|
||||
return None
|
||||
|
||||
async def get_document_symbols(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the symbols defined in a document.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
List of symbol dicts (name, kind, location/range) as returned by
|
||||
the language server, or an empty list when unavailable.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
result = await self._send_request("textDocument/documentSymbol", {
|
||||
"textDocument": {"uri": uri}
|
||||
})
|
||||
|
||||
if not result:
|
||||
return []
|
||||
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_workspace_symbols(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for symbols across the workspace.
|
||||
|
||||
Args:
|
||||
query: Symbol name (or substring) to search for
|
||||
|
||||
Returns:
|
||||
List of symbol dicts (name, kind, location) as returned by the
|
||||
language server, or an empty list when unavailable.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
result = await self._send_request("workspace/symbol", {
|
||||
"query": query
|
||||
})
|
||||
|
||||
if not result:
|
||||
return []
|
||||
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def _send_request(self, method: str, params: Any) -> Any:
|
||||
"""Send a request and wait for response."""
|
||||
if not self.is_running:
|
||||
|
||||
@@ -244,6 +244,82 @@ def _clean_display_content(content: str, max_length: int = 20000) -> str:
|
||||
|
||||
return content.strip()
|
||||
|
||||
def _sanitize_renderable(renderable, stream):
|
||||
"""Replace unencodable decorative glyphs in a Rich renderable/str.
|
||||
|
||||
On legacy consoles (e.g. Windows cp1252) glyphs such as ``▸`` (U+25B8)
|
||||
cannot be encoded and Rich raises ``UnicodeEncodeError``. This walks the
|
||||
common renderable types (plain ``str``, ``Text``, ``Panel``) and rewrites
|
||||
their text through :func:`safe_text` so printing never crashes.
|
||||
"""
|
||||
from .output.encoding import safe_text
|
||||
try:
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
except ImportError:
|
||||
return safe_text(renderable, stream) if isinstance(renderable, str) else renderable
|
||||
|
||||
if isinstance(renderable, str):
|
||||
return safe_text(renderable, stream)
|
||||
if isinstance(renderable, Text):
|
||||
renderable.plain = safe_text(renderable.plain, stream)
|
||||
return renderable
|
||||
if isinstance(renderable, Markdown):
|
||||
# Rebuild from sanitized markup so the rendered body drops
|
||||
# unencodable glyphs (otherwise the LLM response would be lost).
|
||||
return Markdown(safe_text(renderable.markup, stream))
|
||||
if isinstance(renderable, Panel):
|
||||
renderable.renderable = _sanitize_renderable(renderable.renderable, stream)
|
||||
if isinstance(renderable.title, str):
|
||||
renderable.title = safe_text(renderable.title, stream)
|
||||
return renderable
|
||||
return renderable
|
||||
|
||||
def _renderable_to_plain(renderable, stream):
|
||||
"""Best-effort plain-text extraction from a Rich renderable for the
|
||||
last-resort ``print()`` fallback, so LLM responses are never dropped.
|
||||
"""
|
||||
from .output.encoding import safe_text
|
||||
if isinstance(renderable, str):
|
||||
return safe_text(renderable, stream)
|
||||
try:
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
except ImportError:
|
||||
return safe_text(str(getattr(renderable, "plain", renderable)), stream)
|
||||
|
||||
if isinstance(renderable, Text):
|
||||
return safe_text(renderable.plain, stream)
|
||||
if isinstance(renderable, Markdown):
|
||||
return safe_text(renderable.markup, stream)
|
||||
if isinstance(renderable, Panel):
|
||||
return _renderable_to_plain(renderable.renderable, stream)
|
||||
return safe_text(str(getattr(renderable, "plain", renderable)), stream)
|
||||
|
||||
def _safe_console_print(console, *renderables, **kwargs):
|
||||
"""Print via Rich, falling back to ASCII-safe glyphs on encoding errors.
|
||||
|
||||
Rich's legacy Windows renderer encodes with the terminal encoding
|
||||
(cp1252 by default) using strict error handling and has no fallback, so
|
||||
decorative glyphs crash the agent loop. We first try a normal print; on
|
||||
``UnicodeEncodeError`` we retry with sanitized renderables so ``run`` and
|
||||
other display paths degrade gracefully instead of failing.
|
||||
"""
|
||||
try:
|
||||
console.print(*renderables, **kwargs)
|
||||
except UnicodeEncodeError:
|
||||
stream = getattr(console, "file", None)
|
||||
sanitized = [_sanitize_renderable(r, stream) for r in renderables]
|
||||
try:
|
||||
console.print(*sanitized, **kwargs)
|
||||
except UnicodeEncodeError:
|
||||
plain = " ".join(_renderable_to_plain(r, stream) for r in sanitized)
|
||||
# Write to the console's own stream (which may be a captured or
|
||||
# redirected file), not the process-wide sys.stdout.
|
||||
print(plain, file=stream if stream is not None else None)
|
||||
|
||||
def display_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None, metrics=None):
|
||||
"""Synchronous version of display_interaction.
|
||||
|
||||
@@ -309,11 +385,11 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
# Format: 📝 Task message
|
||||
task_prefix = "[bold #FF9B9B]📝[/]"
|
||||
if markdown:
|
||||
console.print(f"{task_prefix} {message}\n")
|
||||
console.print(Panel.fit(Markdown(response_content), title=response_title, border_style=PRAISON_COLORS["response"]))
|
||||
_safe_console_print(console, f"{task_prefix} {message}\n")
|
||||
_safe_console_print(console, Panel.fit(Markdown(response_content), title=response_title, border_style=PRAISON_COLORS["response"]))
|
||||
else:
|
||||
console.print(f"{task_prefix} [bold {PRAISON_COLORS['task_text']}]{message}[/]\n")
|
||||
console.print(Panel.fit(Text(response_content, style=f"bold {PRAISON_COLORS['response_text']}"), title=response_title, border_style=PRAISON_COLORS["response"]))
|
||||
_safe_console_print(console, f"{task_prefix} [bold {PRAISON_COLORS['task_text']}]{message}[/]\n")
|
||||
_safe_console_print(console, Panel.fit(Text(response_content, style=f"bold {PRAISON_COLORS['response_text']}"), title=response_title, border_style=PRAISON_COLORS["response"]))
|
||||
|
||||
def display_self_reflection(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
@@ -326,7 +402,7 @@ def display_self_reflection(message: str, console=None):
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('self_reflection', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
|
||||
def display_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
|
||||
if not message or not message.strip():
|
||||
@@ -347,11 +423,11 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
|
||||
if agent_tools:
|
||||
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
|
||||
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
|
||||
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
_safe_console_print(console, Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
|
||||
# Only print if log level is DEBUG
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
|
||||
def display_tool_call(message: str, console=None, tool_name: str = None, tool_input: dict = None, tool_output: str = None, elapsed_time: float = None, success: bool = True):
|
||||
"""Display tool call information in PraisonAI's unique timeline format.
|
||||
@@ -409,10 +485,10 @@ def display_tool_call(message: str, console=None, tool_name: str = None, tool_in
|
||||
tool_line += f" [dim]→[/] [italic]{output_str}[/]"
|
||||
|
||||
tool_line += f" {time_str} {status_icon}"
|
||||
console.print(tool_line)
|
||||
_safe_console_print(console, tool_line)
|
||||
else:
|
||||
# Legacy format - simple inline
|
||||
console.print(f"[bold #86A789]▸[/] {message}")
|
||||
_safe_console_print(console, f"[bold #86A789]▸[/] {message}")
|
||||
|
||||
def display_error(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
@@ -426,7 +502,7 @@ def display_error(message: str, console=None):
|
||||
execute_sync_callback('error', message=message)
|
||||
|
||||
# Use semantic error color
|
||||
console.print(Panel.fit(
|
||||
_safe_console_print(console, Panel.fit(
|
||||
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
|
||||
title="⚠ Error",
|
||||
border_style=PRAISON_COLORS["error"]
|
||||
@@ -482,7 +558,7 @@ def display_reasoning_steps(steps: List[str], console=None):
|
||||
|
||||
reasoning_display = "\n".join(reasoning_lines)
|
||||
|
||||
console.print(Panel.fit(
|
||||
_safe_console_print(console, Panel.fit(
|
||||
Text(reasoning_display, style=f"italic {PRAISON_COLORS['reasoning_text']}"),
|
||||
title="Reasoning",
|
||||
border_style=PRAISON_COLORS["reasoning"]
|
||||
@@ -550,14 +626,14 @@ async def adisplay_interaction(message, response, markdown=True, generation_time
|
||||
|
||||
# Rest of the display logic...
|
||||
if generation_time:
|
||||
console.print(Text(f"Response generated in {generation_time:.1f}s", style="dim"))
|
||||
_safe_console_print(console, Text(f"Response generated in {generation_time:.1f}s", style="dim"))
|
||||
|
||||
if markdown:
|
||||
console.print(Panel.fit(Markdown(message), title="Task", border_style="cyan"))
|
||||
console.print(Panel.fit(Markdown(response), title="Response", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Markdown(message), title="Task", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Markdown(response), title="Response", border_style="cyan"))
|
||||
else:
|
||||
console.print(Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
|
||||
console.print(Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
|
||||
|
||||
async def adisplay_self_reflection(message: str, console=None):
|
||||
"""Async version of display_self_reflection."""
|
||||
@@ -571,7 +647,7 @@ async def adisplay_self_reflection(message: str, console=None):
|
||||
# Execute callbacks
|
||||
await execute_callback('self_reflection', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
|
||||
async def adisplay_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
|
||||
"""Async version of display_instruction."""
|
||||
@@ -593,11 +669,11 @@ async def adisplay_instruction(message: str, console=None, agent_name: str = Non
|
||||
if agent_tools:
|
||||
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
|
||||
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
|
||||
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
_safe_console_print(console, Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
|
||||
# Only print if log level is DEBUG
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
|
||||
async def adisplay_tool_call(message: str, console=None):
|
||||
"""Async version of display_tool_call."""
|
||||
@@ -614,7 +690,7 @@ async def adisplay_tool_call(message: str, console=None):
|
||||
# Execute callbacks
|
||||
await execute_callback('tool_call', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
|
||||
|
||||
async def adisplay_error(message: str, console=None):
|
||||
"""Async version of display_error."""
|
||||
@@ -628,7 +704,7 @@ async def adisplay_error(message: str, console=None):
|
||||
# Execute callbacks
|
||||
await execute_callback('error', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
|
||||
_safe_console_print(console, Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
|
||||
with _error_logs_lock:
|
||||
error_logs.append(message)
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ from ..memory.mcp_config import MCPConfig, MCPConfigManager
|
||||
from .mcp import MCP
|
||||
|
||||
|
||||
def _sanitize_server_name(name: str) -> str:
|
||||
"""Sanitize a server name for use as a stable, deterministic tool prefix.
|
||||
|
||||
Delegates to :meth:`MCP._sanitize_prefix` so collision detection here can
|
||||
never drift from the prefix actually applied by ``MCP.with_tool_prefix``.
|
||||
"""
|
||||
return MCP._sanitize_prefix(name or "")
|
||||
|
||||
|
||||
def load_mcp_tools(
|
||||
names: Optional[List[str]] = None,
|
||||
*,
|
||||
@@ -28,8 +37,15 @@ def load_mcp_tools(
|
||||
names: Specific config names to load (None = all enabled)
|
||||
configs: Optional injected configs from wrapper TOML loader
|
||||
prefix_tools: Whether to prefix tool names when multiple servers
|
||||
are loaded to avoid collisions (default: True).
|
||||
NOTE: Currently not implemented - collisions possible with multiple servers.
|
||||
are loaded to avoid collisions (default: True). When more than
|
||||
one server is loaded, each server's tools are namespaced as
|
||||
``<server_name>_<tool_name>`` (e.g. ``filesystem_read_file``).
|
||||
Single-server loads keep bare tool names for backward
|
||||
compatibility.
|
||||
|
||||
Raises:
|
||||
ValueError: If two loaded configs share the same (sanitized) server
|
||||
name, which would produce ambiguous namespaced tool names.
|
||||
|
||||
Returns:
|
||||
List of MCP client instances ready for agent use
|
||||
@@ -68,25 +84,49 @@ def load_mcp_tools(
|
||||
else:
|
||||
target_configs = manager.get_enabled_configs()
|
||||
|
||||
for config in target_configs:
|
||||
if not config.enabled:
|
||||
continue
|
||||
|
||||
# Convert config to MCP instance
|
||||
enabled_configs = [c for c in target_configs if c and c.enabled]
|
||||
|
||||
# Detect residual collisions: two servers whose sanitized names are
|
||||
# identical would produce ambiguous namespaced tool names. Fail loudly
|
||||
# instead of silently shadowing (the very bug this loader guards against).
|
||||
if prefix_tools and len(enabled_configs) > 1:
|
||||
seen: dict = {}
|
||||
for config in enabled_configs:
|
||||
sanitized = _sanitize_server_name(config.name)
|
||||
if sanitized in seen:
|
||||
raise ValueError(
|
||||
f"MCP server name collision: '{config.name}' and "
|
||||
f"'{seen[sanitized]}' both map to prefix '{sanitized}'. "
|
||||
"Use distinct server names to keep tool namespacing unambiguous."
|
||||
)
|
||||
seen[sanitized] = config.name
|
||||
|
||||
# First materialize the MCP instances and apply per-server tool filters.
|
||||
# We defer the prefixing decision until we know how many instances were
|
||||
# actually created: a config may yield None (e.g. missing optional deps),
|
||||
# and a lone surviving server must keep bare names for backward compat.
|
||||
loaded: list = []
|
||||
for config in enabled_configs:
|
||||
mcp = config.to_mcp_instance()
|
||||
if mcp:
|
||||
# Apply tool filtering if configured (B3)
|
||||
# TODO: Wire include/exclude filters when B3 is implemented
|
||||
|
||||
# Apply tool prefix if multiple servers (B4)
|
||||
if prefix_tools and len(target_configs) > 1:
|
||||
# Sanitize server name for use as prefix
|
||||
server_name = config.name.replace('-', '_').replace(' ', '_')
|
||||
# TODO: Tool name prefixing not yet implemented
|
||||
# Need to modify tool names in MCP instance to avoid collisions
|
||||
# e.g., "read_file" -> "filesystem_read_file"
|
||||
pass
|
||||
|
||||
mcp_clients.append(mcp)
|
||||
|
||||
if not mcp:
|
||||
continue
|
||||
|
||||
# Apply include/exclude tool filtering if configured (B3).
|
||||
# Filters may be declared on the config (forward-compatible via
|
||||
# getattr so older MCPConfig objects without these fields work).
|
||||
allowed = getattr(config, "allowed_tools", None) or getattr(config, "include_tools", None)
|
||||
disabled = getattr(config, "disabled_tools", None) or getattr(config, "exclude_tools", None)
|
||||
if allowed or disabled:
|
||||
mcp.apply_tool_filters(allowed_tools=allowed, disabled_tools=disabled)
|
||||
|
||||
loaded.append((config, mcp))
|
||||
|
||||
# Apply tool prefix only when more than one server was actually loaded
|
||||
# (B4) to avoid collisions while preserving single-server bare names.
|
||||
multiple_servers = len(loaded) > 1
|
||||
for config, mcp in loaded:
|
||||
if prefix_tools and multiple_servers:
|
||||
mcp.with_tool_prefix(config.name)
|
||||
mcp_clients.append(mcp)
|
||||
|
||||
return mcp_clients
|
||||
@@ -258,6 +258,10 @@ class MCP:
|
||||
self.debug = debug
|
||||
self.allowed_tools = allowed_tools
|
||||
self.disabled_tools = disabled_tools
|
||||
|
||||
# Optional prefix applied to tool names to avoid cross-server collisions
|
||||
# when multiple MCP servers are loaded together (see load_mcp_tools).
|
||||
self._tool_prefix: Optional[str] = None
|
||||
|
||||
# Check if this is a WebSocket URL (ws:// or wss://)
|
||||
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
|
||||
@@ -550,7 +554,33 @@ class MCP:
|
||||
filtered_tools.append(tool_def)
|
||||
|
||||
return filtered_tools
|
||||
|
||||
|
||||
def apply_tool_filters(
|
||||
self,
|
||||
allowed_tools: Optional[List[str]] = None,
|
||||
disabled_tools: Optional[List[str]] = None,
|
||||
) -> "MCP":
|
||||
"""Public API to (re)apply allow/deny tool filters to this instance.
|
||||
|
||||
Lets callers (e.g. the loader) express include/exclude intent without
|
||||
reaching into private state. ``allowed_tools`` wins over
|
||||
``disabled_tools`` when both are supplied.
|
||||
|
||||
Args:
|
||||
allowed_tools: Only these tool names are kept (allowlist).
|
||||
disabled_tools: These tool names are removed (denylist).
|
||||
|
||||
Returns:
|
||||
self, to allow fluent chaining.
|
||||
"""
|
||||
if allowed_tools is not None:
|
||||
self.allowed_tools = allowed_tools
|
||||
if disabled_tools is not None:
|
||||
self.disabled_tools = disabled_tools
|
||||
if self.allowed_tools or self.disabled_tools:
|
||||
self._tools = self._apply_tool_filters(self._tools)
|
||||
return self
|
||||
|
||||
def _build_safe_env(self, custom_env: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Build a safe environment for stdio MCP servers.
|
||||
@@ -609,6 +639,65 @@ class MCP:
|
||||
"""
|
||||
return iter(self._tools)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_prefix(prefix: str) -> str:
|
||||
"""Sanitize a server name into a safe tool-name prefix."""
|
||||
sanitized = re.sub(r"[^0-9A-Za-z_]", "_", prefix or "")
|
||||
return sanitized.strip("_")
|
||||
|
||||
def _prefixed_name(self, original_name: str) -> str:
|
||||
"""Return the namespaced tool name for an original server-side name."""
|
||||
if not self._tool_prefix:
|
||||
return original_name
|
||||
return f"{self._tool_prefix}_{original_name}"
|
||||
|
||||
def with_tool_prefix(self, prefix: str) -> "MCP":
|
||||
"""
|
||||
Namespace this server's tool names with ``<prefix>_`` to avoid
|
||||
collisions when multiple MCP servers are loaded together.
|
||||
|
||||
Both the callable tool names (``__name__``) and the OpenAI schema
|
||||
names produced by :meth:`to_openai_tool` are prefixed, while calls
|
||||
are still dispatched to the original server-side tool names.
|
||||
|
||||
Args:
|
||||
prefix: The server name to derive the prefix from. It is
|
||||
sanitized to contain only ``[0-9A-Za-z_]`` characters.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``prefix`` sanitizes to an empty string, since an
|
||||
empty prefix would silently reintroduce the cross-server name
|
||||
collisions this method exists to prevent.
|
||||
|
||||
Returns:
|
||||
self, to allow fluent chaining.
|
||||
"""
|
||||
sanitized = self._sanitize_prefix(prefix)
|
||||
if not sanitized:
|
||||
raise ValueError(
|
||||
f"Cannot derive a valid tool prefix from server name {prefix!r}; "
|
||||
"provide a name containing at least one alphanumeric or "
|
||||
"underscore character."
|
||||
)
|
||||
|
||||
self._tool_prefix = sanitized
|
||||
|
||||
# Rename already-generated callable tools. Dispatch inside each
|
||||
# wrapper closes over the original tool name, so only the public
|
||||
# __name__/__qualname__ needs updating for schema construction.
|
||||
for tool in getattr(self, "_tools", []) or []:
|
||||
if callable(tool) and hasattr(tool, "__name__"):
|
||||
original = getattr(tool, "__original_name__", tool.__name__)
|
||||
tool.__original_name__ = original
|
||||
new_name = f"{sanitized}_{original}"
|
||||
tool.__name__ = new_name
|
||||
try:
|
||||
tool.__qualname__ = new_name
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
return self
|
||||
|
||||
def get_tools(self) -> List[Callable]:
|
||||
"""
|
||||
Get the list of tool functions from this MCP instance.
|
||||
@@ -656,11 +745,11 @@ class MCP:
|
||||
"""
|
||||
if self.is_sse and hasattr(self, 'sse_client') and self.sse_client.tools:
|
||||
# Return all tools from SSE client
|
||||
return self.sse_client.to_openai_tools()
|
||||
return self._apply_prefix_to_openai_tools(self.sse_client.to_openai_tools())
|
||||
|
||||
if self.is_http_stream and hasattr(self, 'http_stream_client') and self.http_stream_client.tools:
|
||||
# Return all tools from HTTP Stream client
|
||||
return self.http_stream_client.to_openai_tools()
|
||||
return self._apply_prefix_to_openai_tools(self.http_stream_client.to_openai_tools())
|
||||
|
||||
# For simplicity, we'll convert the first tool only if multiple exist
|
||||
# More complex implementations could handle multiple tools
|
||||
@@ -687,7 +776,7 @@ class MCP:
|
||||
openai_tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"name": self._prefixed_name(tool.name),
|
||||
"description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
|
||||
"parameters": parameters
|
||||
},
|
||||
@@ -695,6 +784,34 @@ class MCP:
|
||||
})
|
||||
|
||||
return openai_tools
|
||||
|
||||
def _apply_prefix_to_openai_tools(self, openai_tools):
|
||||
"""Apply the configured tool prefix to OpenAI schema tool names.
|
||||
|
||||
Used for transports (SSE/HTTP/WebSocket) whose clients build their
|
||||
own OpenAI schemas so their names stay in sync with the prefixed
|
||||
callable ``__name__`` values consumed during dispatch.
|
||||
|
||||
Returns a new list with copied dicts so the call is idempotent and
|
||||
never mutates the clients' internal tool representations (avoids
|
||||
double-prefixing if a client caches its schema dicts).
|
||||
"""
|
||||
if not self._tool_prefix or not openai_tools:
|
||||
return openai_tools
|
||||
|
||||
is_list = isinstance(openai_tools, list)
|
||||
items = openai_tools if is_list else [openai_tools]
|
||||
result = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
item = dict(item)
|
||||
fn = item.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
item["function"] = dict(fn)
|
||||
item["function"]["name"] = self._prefixed_name(fn["name"])
|
||||
result.append(item)
|
||||
|
||||
return result if is_list else result[0]
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry - return self for use in 'with' statements."""
|
||||
|
||||
@@ -9,6 +9,7 @@ Adapters:
|
||||
- in_memory_adapter: Lightweight in-memory storage (core, for testing/dev)
|
||||
- chromadb_adapter: ChromaDB vector memory storage (wrapper)
|
||||
- mongodb_adapter: MongoDB document storage (wrapper)
|
||||
- dakera_adapter: Dakera decay-weighted memory server (wrapper)
|
||||
- redis_adapter: Redis in-memory storage (wrapper)
|
||||
- postgres_adapter: PostgreSQL storage (wrapper)
|
||||
|
||||
@@ -30,6 +31,7 @@ from .factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
create_dakera_memory_adapter,
|
||||
)
|
||||
|
||||
# Register core adapters
|
||||
@@ -40,6 +42,7 @@ register_memory_adapter("in_memory", InMemoryAdapter)
|
||||
register_memory_factory("mem0", create_mem0_memory_adapter)
|
||||
register_memory_factory("chroma", create_chroma_memory_adapter)
|
||||
register_memory_factory("mongodb", create_mongodb_memory_adapter)
|
||||
register_memory_factory("dakera", create_dakera_memory_adapter)
|
||||
|
||||
__all__ = [
|
||||
'SqliteMemoryAdapter',
|
||||
|
||||
@@ -14,11 +14,41 @@ This approach follows the protocol-driven core principle by moving heavy impleme
|
||||
out of the core Memory class while preserving backward compatibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_mem0_search(mem0_client, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Defensive wrapper for mem0.search() to handle MongoDB vector store compatibility.
|
||||
|
||||
Catches the specific TypeError about an unexpected 'vectors' kwarg and falls back
|
||||
gracefully by returning an empty result set. This addresses the upstream mem0 bug:
|
||||
https://github.com/mem0ai/mem0/issues/3185
|
||||
|
||||
Any other TypeError is re-raised unchanged.
|
||||
"""
|
||||
try:
|
||||
return mem0_client.search(**kwargs)
|
||||
except TypeError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "unexpected keyword argument" in error_msg and "vectors" in error_msg:
|
||||
logger.warning(
|
||||
"Detected mem0 MongoDB vector store compatibility issue. "
|
||||
"This is a known upstream bug: https://github.com/mem0ai/mem0/issues/3185. "
|
||||
"The MongoDB vector store requires Atlas and has signature mismatches. "
|
||||
"Consider using Qdrant or Chroma as mem0 vector store backends instead."
|
||||
)
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
# Re-raise if it's a different TypeError
|
||||
raise
|
||||
|
||||
|
||||
def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
@@ -101,6 +131,47 @@ def create_mongodb_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
return MongoDBMemoryAdapter(pymongo=pymongo, mongo_client=MongoClient, **kwargs)
|
||||
|
||||
|
||||
def create_dakera_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create a Dakera memory adapter.
|
||||
|
||||
Lazy imports the ``dakera`` SDK and creates an adapter that wraps a
|
||||
``DakeraClient`` to implement ``MemoryProtocol``. Dakera is a self-hosted
|
||||
memory server providing decay-weighted vector recall scoped by ``agent_id``.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to the adapter. Recognised keys (either
|
||||
at the top level or nested under a ``"config"`` dict, mirroring the
|
||||
mem0 adapter):
|
||||
|
||||
- ``url`` / ``base_url``: Dakera server URL
|
||||
(falls back to ``DAKERA_URL`` / ``DAKERA_API_URL`` env, then
|
||||
``http://localhost:3000``).
|
||||
- ``api_key``: API key (falls back to ``DAKERA_API_KEY`` env).
|
||||
- ``agent_id``: Namespace for the agent's memories
|
||||
(falls back to ``DAKERA_AGENT_ID`` env, then ``"praisonai"``).
|
||||
- ``short_term_type`` / ``long_term_type``: Dakera memory types to
|
||||
use for the two tiers (default ``"working"`` and ``"episodic"``).
|
||||
- ``default_importance``: Importance score for stored memories
|
||||
when none is supplied (default ``0.5``).
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance.
|
||||
|
||||
Raises:
|
||||
ImportError: If the ``dakera`` SDK is not installed.
|
||||
"""
|
||||
try:
|
||||
import dakera # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"dakera is required for the dakera adapter. "
|
||||
"Install with: pip install 'praisonaiagents[dakera]' (or: pip install dakera)"
|
||||
) from exc
|
||||
|
||||
return DakeraMemoryAdapter(dakera_config=kwargs)
|
||||
|
||||
|
||||
class Mem0MemoryAdapter:
|
||||
"""
|
||||
Memory adapter that wraps mem0.Memory to implement MemoryProtocol.
|
||||
@@ -158,15 +229,8 @@ class Mem0MemoryAdapter:
|
||||
"""Search mem0 for relevant memories."""
|
||||
search_params = {"query": query, "limit": limit}
|
||||
search_params.update(kwargs)
|
||||
|
||||
try:
|
||||
return self.mem0_client.search(**search_params)
|
||||
except TypeError as e:
|
||||
# Handle known mem0 MongoDB compatibility issue
|
||||
if "unexpected keyword argument" in str(e).lower() and "vectors" in str(e).lower():
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
raise
|
||||
|
||||
return safe_mem0_search(self.mem0_client, **search_params)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from mem0."""
|
||||
@@ -510,4 +574,179 @@ class MongoDBMemoryAdapter:
|
||||
import logging
|
||||
logging.warning(f"MongoDB cleanup failed: {e}")
|
||||
finally:
|
||||
self.client = None
|
||||
self.client = None
|
||||
|
||||
|
||||
class DakeraMemoryAdapter:
|
||||
"""
|
||||
Memory adapter that wraps the Dakera SDK to implement ``MemoryProtocol``.
|
||||
|
||||
Dakera (https://dakera.ai) is a self-hosted memory server that provides
|
||||
persistent, decay-weighted vector recall across sessions: memories are
|
||||
importance-scored and decay over time, so stale context stops competing
|
||||
with fresh, relevant facts. All memories are scoped by ``agent_id``.
|
||||
|
||||
Unlike a flat vector store, Dakera has a first-class ``memory_type`` field,
|
||||
so this adapter maps PraisonAI's two tiers onto distinct Dakera types
|
||||
(short-term -> ``"working"``, long-term -> ``"episodic"`` by default),
|
||||
keeping recency-heavy scratch context separate from durable knowledge.
|
||||
|
||||
Also implements the optional ``DeletableMemoryProtocol`` (``delete_memory`` /
|
||||
``delete_memories``) and ``ResettableMemoryProtocol`` (``reset_short_term`` /
|
||||
``reset_long_term``).
|
||||
"""
|
||||
|
||||
def __init__(self, dakera_config: Dict[str, Any]):
|
||||
"""Initialise the Dakera memory adapter."""
|
||||
from dakera import DakeraClient
|
||||
|
||||
# Support both a flat config and a nested {"config": {...}} form,
|
||||
# mirroring Mem0MemoryAdapter.
|
||||
config = dakera_config.get("config", dakera_config) or {}
|
||||
|
||||
url = (
|
||||
config.get("url")
|
||||
or config.get("base_url")
|
||||
or os.getenv("DAKERA_URL")
|
||||
or os.getenv("DAKERA_API_URL")
|
||||
or "http://localhost:3000"
|
||||
)
|
||||
api_key = config.get("api_key") or os.getenv("DAKERA_API_KEY")
|
||||
|
||||
self.agent_id = (
|
||||
config.get("agent_id")
|
||||
or os.getenv("DAKERA_AGENT_ID")
|
||||
or "praisonai"
|
||||
)
|
||||
self.short_term_type = config.get("short_term_type", "working")
|
||||
self.long_term_type = config.get("long_term_type", "episodic")
|
||||
self.default_importance = config.get("default_importance", 0.5)
|
||||
|
||||
self.client = DakeraClient(base_url=url, api_key=api_key)
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
def _store(self, text: str, memory_type: str,
|
||||
metadata: Optional[Dict[str, Any]], kwargs: Dict[str, Any]) -> str:
|
||||
"""Store a memory of ``memory_type`` and return its id."""
|
||||
meta = dict(metadata or {})
|
||||
# Always strip reserved keys from metadata so they never leak into the
|
||||
# stored payload, then let explicit kwargs take precedence over them.
|
||||
meta_importance = meta.pop("importance", None)
|
||||
meta_session_id = meta.pop("session_id", None)
|
||||
meta_tags = meta.pop("tags", None)
|
||||
|
||||
importance = kwargs.get("importance")
|
||||
if importance is None:
|
||||
importance = (
|
||||
meta_importance if meta_importance is not None else self.default_importance
|
||||
)
|
||||
session_id = kwargs.get("session_id") or meta_session_id
|
||||
tags = kwargs.get("tags") or meta_tags
|
||||
|
||||
result = self.client.store_memory(
|
||||
agent_id=self.agent_id,
|
||||
content=text,
|
||||
memory_type=memory_type,
|
||||
importance=importance,
|
||||
metadata=meta or None,
|
||||
session_id=session_id,
|
||||
tags=tags,
|
||||
)
|
||||
# store_memory returns the stored memory dict.
|
||||
return str(result.get("id", "")) if isinstance(result, dict) else str(result)
|
||||
|
||||
def _search(self, query: str, memory_type: str,
|
||||
limit: int, kwargs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Recall memories of ``memory_type`` matching ``query``."""
|
||||
response = self.client.recall(
|
||||
agent_id=self.agent_id,
|
||||
query=query,
|
||||
top_k=limit,
|
||||
memory_type=memory_type,
|
||||
min_importance=kwargs.get("min_importance"),
|
||||
)
|
||||
return [self._to_result(m) for m in response.memories]
|
||||
|
||||
@staticmethod
|
||||
def _to_result(memory: Any) -> Dict[str, Any]:
|
||||
"""Normalise a Dakera memory object into PraisonAI's result dict shape."""
|
||||
return {
|
||||
"id": str(getattr(memory, "id", "")),
|
||||
"text": getattr(memory, "content", ""),
|
||||
"metadata": getattr(memory, "metadata", None) or {},
|
||||
"score": getattr(memory, "score", None),
|
||||
"memory_type": getattr(memory, "memory_type", None),
|
||||
}
|
||||
|
||||
# -- MemoryProtocol -----------------------------------------------------
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> str:
|
||||
"""Store content in short-term (``working``) memory."""
|
||||
return self._store(text, self.short_term_type, metadata, kwargs)
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5,
|
||||
**kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search short-term (``working``) memory."""
|
||||
return self._search(query, self.short_term_type, limit, kwargs)
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> str:
|
||||
"""Store content in long-term (``episodic``) memory."""
|
||||
return self._store(text, self.long_term_type, metadata, kwargs)
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5,
|
||||
**kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search long-term (``episodic``) memory."""
|
||||
return self._search(query, self.long_term_type, limit, kwargs)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Return all memories for the agent (no embedding required)."""
|
||||
from dakera import BatchRecallRequest
|
||||
|
||||
limit = kwargs.get("limit", 1000)
|
||||
response = self.client.batch_recall(
|
||||
BatchRecallRequest(agent_id=self.agent_id, limit=limit)
|
||||
)
|
||||
return [self._to_result(m) for m in response.memories]
|
||||
|
||||
# -- DeletableMemoryProtocol -------------------------------------------
|
||||
|
||||
def delete_memory(self, memory_id: str,
|
||||
memory_type: Optional[str] = None) -> bool:
|
||||
"""Delete a specific memory by id. Returns True on success."""
|
||||
try:
|
||||
self.client.forget(agent_id=self.agent_id, memory_id=memory_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(
|
||||
f"Dakera delete_memory({memory_id}) failed for agent "
|
||||
f"'{self.agent_id}': {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""Delete multiple memories by id. Returns the number deleted."""
|
||||
return sum(1 for mid in memory_ids if self.delete_memory(mid))
|
||||
|
||||
# -- ResettableMemoryProtocol ------------------------------------------
|
||||
|
||||
def _reset_type(self, memory_type: str) -> None:
|
||||
from dakera import BatchForgetRequest, BatchMemoryFilter
|
||||
|
||||
self.client.batch_forget(
|
||||
BatchForgetRequest(
|
||||
agent_id=self.agent_id,
|
||||
filter=BatchMemoryFilter(memory_type=memory_type),
|
||||
)
|
||||
)
|
||||
|
||||
def reset_short_term(self) -> None:
|
||||
"""Clear all short-term (``working``) memory for the agent."""
|
||||
self._reset_type(self.short_term_type)
|
||||
|
||||
def reset_long_term(self) -> None:
|
||||
"""Clear all long-term (``episodic``) memory for the agent."""
|
||||
self._reset_type(self.long_term_type)
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
Legacy Memory Adapter
|
||||
|
||||
This adapter wraps the existing Memory class to provide backward compatibility
|
||||
while demonstrating the protocol-driven approach. This allows the core Memory
|
||||
class to be gradually refactored while maintaining all existing functionality.
|
||||
|
||||
This approach follows the Strangler Fig pattern:
|
||||
1. Create adapters that wrap existing implementations
|
||||
2. Register them in the adapter registry
|
||||
3. Core classes use adapters via registry instead of direct imports
|
||||
4. Gradually move logic from legacy class to clean adapters
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
|
||||
class LegacyMemoryAdapter:
|
||||
"""
|
||||
Adapter that wraps the existing Memory class to implement MemoryProtocol.
|
||||
|
||||
This enables the existing Memory implementation to work through the
|
||||
adapter registry while we gradually refactor it to be more protocol-driven.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize legacy memory adapter by wrapping existing Memory class."""
|
||||
# Import the original Memory class here to avoid circular imports
|
||||
from .. import memory as memory_module
|
||||
|
||||
# Create instance of the original Memory class
|
||||
config = kwargs.get("config", kwargs)
|
||||
verbose = kwargs.get("verbose", 0)
|
||||
|
||||
self._memory = memory_module.Memory(config=config, verbose=verbose)
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in short-term memory via legacy Memory class."""
|
||||
self._memory.store_short_term(text, metadata=metadata, **kwargs)
|
||||
# Generate a stable UUID instead of using unstable id(text)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory via legacy Memory class."""
|
||||
return self._memory.search_short_term(query, limit=limit, **kwargs)
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in long-term memory via legacy Memory class."""
|
||||
self._memory.store_long_term(text, metadata=metadata, **kwargs)
|
||||
# Generate a stable UUID instead of using unstable id(text)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search long-term memory via legacy Memory class."""
|
||||
return self._memory.search_long_term(query, limit=limit, **kwargs)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories via legacy Memory class."""
|
||||
return self._memory.get_all_memories(**kwargs)
|
||||
|
||||
|
||||
def create_legacy_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create legacy memory adapter.
|
||||
|
||||
This factory enables the existing Memory class to work through the
|
||||
adapter registry without requiring immediate refactoring.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to legacy Memory class
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance wrapping legacy Memory
|
||||
"""
|
||||
return LegacyMemoryAdapter(**kwargs)
|
||||
Loaded 100 of 1027 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user