Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 4140e499da fix: correct MCP SSE launch, shebang ordering, and E2E helper scripts
- execution_mixin: replace non-existent FastMCP.create_app() with the
  repo's working low-level SSE transport pattern (connect_sse +
  handle_post_message), matching mcp/mcp_server.py; normalize base_path
  leading slash. Fixes AttributeError at MCP server startup.
- examples (36 files): move '# praisonai: skip=true' below the shebang so
  direct execution still works; directive stays within the runner's
  first-30-lines scan.
- failover_example: display real 'last_error' instead of unmodeled
  'failure_count' (which always printed 0).
- scripts/apply_e2e_batch3_*.py: make skip insertion shebang-aware, wire
  the previously-uncalled fix_mcp_env_examples(), and make the E2E report
  path a CLI argument (defaulting to the audit path).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-23 12:10:41 +00:00
398 changed files with 3375 additions and 26290 deletions

No files matched your search

+4 -28
View File
@@ -36,10 +36,8 @@ assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
},
},
};
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseToday(mockGithub, 'o', 'r', noonUtc),
true
);
@@ -48,34 +46,12 @@ assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc),
false
);
assert.ok(result.reasons.some((r) => r.includes('already released today')));
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: hasSuccessfulReleaseWithinDays');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: hasSuccessfulReleaseToday');
console.log('ok: daily dedupe blocks second release');
})();
+6 -28
View File
@@ -5,8 +5,6 @@
const https = require('https');
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
@@ -81,18 +79,8 @@ function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
@@ -101,15 +89,10 @@ async function hasSuccessfulReleaseWithinDays(
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -152,11 +135,9 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
@@ -241,9 +222,6 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
releaseIntervalStart,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+3 -3
View File
@@ -1,12 +1,12 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Triggers: Core Tests success on main (workflow_run) or cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
# Triggers: Core Tests success on main (workflow_run) or nightly cron at 00:00 UTC.
# Dedupe: max one successful patch release per UTC day (see release-gate.js).
on:
schedule:
- cron: '0 0 */3 * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
+1 -3
View File
@@ -134,9 +134,7 @@ jobs:
'pypi-auto' || 'pypi'
}}
env:
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Checkout main
-3
View File
@@ -782,9 +782,6 @@ jobs:
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/bots/test_enable_shell.py
../praisonai-bot/tests/unit/gateway/test_preflight.py
../praisonai-bot/tests/unit/gateway/test_gateway_doctor.py
../praisonai-bot/tests/unit/test_bot_history.py
../praisonai-bot/tests/unit/test_bot_fixes.py
tests/unit/test_auto_lazy_loading.py
+1 -7
View File
@@ -83,7 +83,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str = '', threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False, goal: Optional[str] = None, goal_criteria: Optional[Any] = None, judge_model: Optional[str] = None) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">set_snapshot_root</a>(project_path: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', action: str = 'add', **kwargs: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>() -> Optional[Any]</code>
@@ -569,7 +568,6 @@ Methods:
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_prompts</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_resources</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_tools</a>() -> List[Callable]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">list_active_server_names</a>() -> set</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">shutdown</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">to_openai_tool</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">with_tool_prefix</a>(prefix: str) -> 'MCP'</code>
@@ -733,7 +731,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, RunOutcome, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -801,16 +799,12 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[crewai]"
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
CI Integration Example - Using Doctor in CI/CD pipelines
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
PraisonAI Endpoints Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Fixed example demonstrating proper guardrail usage with PraisonAI Agents.
This addresses the issues reported in issue #875.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + ClickHouse Persistence — Real conversation with session resume.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + MongoDB Persistence — Real conversation with session resume.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + MySQL Persistence — Real conversation with session resume.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + PostgreSQL Persistence — Real conversation with session resume.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + Redis Persistence — Real conversation with session resume.
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
ManagedAgent + SQLite Persistence — Real conversation with session resume.
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Custom Tools MCP Server Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
HTTP Stream MCP Server Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
MCP Client Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Context Agent Example - Basic Usage
+1 -11
View File
@@ -1,15 +1,5 @@
import threading
import time
from praisonaiagents import Agent
if __name__ == "__main__":
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
server = threading.Thread(
target=lambda: agent.launch(port=8080, protocol="mcp", path="/mcp"),
daemon=True,
)
server.start()
time.sleep(2)
print("MCP server started on http://127.0.0.1:8080/mcp")
agent.launch(port=8080, protocol="mcp", path="/mcp")
@@ -150,7 +150,7 @@ def main():
agent = Agent(
name="AI Research Assistant",
instructions="You are an AI research assistant. Use your knowledge base to answer questions about artificial intelligence topics.",
knowledge={"sources": sample_documents, "rerank": True},
knowledge={**rerank_config, "sources": sample_documents},
llm="gpt-4o-mini"
)
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Minimal Example: Latency Tracking for MCP Server
+1 -1
View File
@@ -66,7 +66,7 @@ def demonstrate_failover():
print(f" {profile_info.get('name', 'unknown')}:")
print(f" Status: {profile_info.get('status', 'unknown')}")
print(f" Priority: {profile_info.get('priority', 'N/A')}")
print(f" Failures: {profile_info.get('failure_count', 0)}")
print(f" Last error: {profile_info.get('last_error') or 'none'}")
print()
# Get next available profile
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Managed Agent Backend Example — Real end-to-end test.
@@ -67,7 +67,6 @@ agents_system = AgentTeam(
print("Starting graph memory demonstration...")
result = agents_system.start()
result_text = str(result) if result is not None else ""
print(f"\nGraph Memory Result: {result_text[:200]}...")
print(f"\nGraph Memory Result: {result[:200]}...")
print("\n✅ Graph memory integration complete!")
print("Agent built knowledge graph and performed relationship-aware queries.")
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Comprehensive MongoDB Integration Example for PraisonAI Agents
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
MongoDB Tools Example for PraisonAI Agents
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Memory Quality Management Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python
# praisonai: skip=true
# coding: utf-8
# # News and Podcast Aggregator Agent using Agents
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
SearxNG Search Tool Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
AI Brief Generator Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
AI Hook Generator Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
AI News Crawler Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
AI Script Writer Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Example: LLM-based Recipes
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
HTTP Recipe Registry Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""Run all examples and collect PASS/SKIP/FAIL results."""
import subprocess
import sys
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
A2A (Agent-to-Agent) Server and Client Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
A2U (Agent-to-User) Event Stream Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Multi-Agent Router as HTTP API Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Unified Endpoints Client Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
MCP HTTP Server Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
PraisonAI Recipe Server Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Tools as MCP Server Example
+1 -1
View File
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Unified Server Example
@@ -1,5 +1,5 @@
# praisonai: skip=true
#!/usr/bin/env python3
# praisonai: skip=true
"""
Agent Comparison Test - Run 5 tasks with both wrapper and direct agent
+18 -18
View File
@@ -14,18 +14,18 @@ def test_imports():
"""Test that all required modules can be imported."""
try:
from praisonaiagents import Agent
print("[OK] Agent imported successfully")
print("✅ Agent imported successfully")
from praisonaiagents.tools import execute_command
print("[OK] execute_command tool imported successfully")
print("✅ execute_command tool imported successfully")
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
print("[OK] Approval system imported successfully")
print("✅ Approval system imported successfully")
return True
except ImportError as e:
print(f"[FAIL] Import failed: {ascii(e)}")
print(f"❌ Import failed: {e}")
return False
def test_agent_creation():
@@ -40,11 +40,11 @@ def test_agent_creation():
)
assert agent.name == 'test-agent'
print("[OK] Agent creation successful")
print("✅ Agent creation successful")
return True
except Exception as e:
print(f"[FAIL] Agent creation failed: {ascii(e)}")
print(f"❌ Agent creation failed: {e}")
return False
def test_approval_system():
@@ -55,11 +55,11 @@ def test_approval_system():
registry = get_approval_registry()
registry.set_backend(AutoApproveBackend())
print("[OK] Approval system configuration successful")
print("✅ Approval system configuration successful")
return True
except Exception as e:
print(f"[FAIL] Approval system test failed: {ascii(e)}")
print(f"❌ Approval system test failed: {e}")
return False
def test_external_agent_import():
@@ -80,11 +80,11 @@ def test_external_agent_import():
# Now try to import our agent
import praisonai_external_agent
print("[OK] External agent import successful")
print("✅ External agent import successful")
return True
except Exception as e:
print(f"[FAIL] External agent import failed: {ascii(e)}")
print(f"❌ External agent import failed: {e}")
return False
def test_multi_agent_import():
@@ -104,11 +104,11 @@ def test_multi_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import multi_agent_example
print("[OK] Multi-agent example import successful")
print("✅ Multi-agent example import successful")
return True
except Exception as e:
print(f"[FAIL] Multi-agent example import failed: {ascii(e)}")
print(f"❌ Multi-agent example import failed: {e}")
return False
def test_wrapper_agent_import():
@@ -128,11 +128,11 @@ def test_wrapper_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import praisonai_wrapper_agent
print("[OK] Wrapper agent import successful (CLI-based approach)")
print("✅ Wrapper agent import successful (CLI-based approach)")
return True
except Exception as e:
print(f"[FAIL] Wrapper agent import failed: {ascii(e)}")
print(f"❌ Wrapper agent import failed: {e}")
return False
if __name__ == "__main__":
@@ -152,19 +152,19 @@ if __name__ == "__main__":
total = len(tests)
for test_name, test_func in tests:
print(f"\n[TEST] {test_name}...")
print(f"\n🧪 Testing {test_name}...")
try:
if test_func():
passed += 1
except Exception as e:
print(f"[FAIL] Test {test_name} crashed: {ascii(e)}")
print(f"❌ Test {test_name} crashed: {e}")
print("\n" + "=" * 55)
print(f"Results: {passed}/{total} tests passed")
if passed == total:
print("[OK] ALL BASIC TESTS PASSED!")
print("🎉 ALL BASIC TESTS PASSED!")
print("The Terminal-Bench integration components are properly structured.")
else:
print("[FAIL] Some tests failed - check output above")
print("❌ Some tests failed - check output above")
sys.exit(1)
+2 -4
View File
@@ -30,11 +30,9 @@ def main():
else:
print(f"Found {len(stories)} top stories:")
for story in stories:
title = story.get("title") or "N/A"
url = story.get("url") or "N/A"
print(f" - {title[:50]}...")
print(f" - {story.get('title', 'N/A')[:50]}...")
print(f" Score: {story.get('score', 'N/A')} | Comments: {story.get('descendants', 'N/A')}")
print(f" URL: {url[:60]}...")
print(f" URL: {story.get('url', 'N/A')[:60]}...")
print()
print("✅ Hacker News tool working correctly!")
-8
View File
@@ -19,9 +19,6 @@ agents:
channels:
telegram:
token: "${TELEGRAM_BOT_TOKEN}"
# allow_shell: true
# auto_approve_shell: false
# approval_channel: "123456789" # Telegram chat ID for shell approvals
routing:
dm: "personal"
group: "support"
@@ -29,9 +26,6 @@ channels:
discord:
token: "${DISCORD_BOT_TOKEN}"
# allow_shell: true
# auto_approve_shell: false
# approval_channel: "9876543210" # Discord channel ID (or use home_channel)
routing:
dm: "personal"
channel: "support"
@@ -40,8 +34,6 @@ channels:
slack:
token: "${SLACK_BOT_TOKEN}"
app_token: "${SLACK_APP_TOKEN}"
allow_shell: true
auto_approve_shell: true
routing:
dm: "personal"
channel: "support"
+11 -2
View File
@@ -9,9 +9,15 @@ ROOT = Path(__file__).resolve().parents[1]
def _prepend_skip(rel: str) -> None:
path = ROOT / rel
text = path.read_text(encoding="utf-8")
if "# praisonai: skip=true" in text.splitlines()[:3]:
lines = text.splitlines(keepends=True)
if any("# praisonai: skip=true" in ln for ln in lines[:3]):
return
path.write_text("# praisonai: skip=true\n" + text, encoding="utf-8")
# Preserve a shebang on the first line so direct execution still works.
if lines and lines[0].startswith("#!"):
lines.insert(1, "# praisonai: skip=true\n")
path.write_text("".join(lines), encoding="utf-8")
else:
path.write_text("# praisonai: skip=true\n" + text, encoding="utf-8")
def fix_knowledge_reranker() -> None:
@@ -133,6 +139,9 @@ def main() -> None:
fix_knowledge_reranker()
print("fixed knowledge-reranker")
fix_mcp_env_examples()
print("fixed MCP environment examples")
# advanced-task-management TaskOutput import
p = ROOT / "examples/python/tasks/advanced-task-management.py"
if p.exists():
+20 -3
View File
@@ -1,12 +1,13 @@
"""Apply batch-3 E2E fixes: skip directives for non-runnable examples."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REPORT = Path(__file__).resolve().parents[2] / "PraisonAI-main-e2e" / "examples-e2e-reports" / "post_merge_20260723_132751" / "report.json"
DEFAULT_REPORT = Path(__file__).resolve().parents[2] / "PraisonAI-main-e2e" / "examples-e2e-reports" / "post_merge_20260723_132751" / "report.json"
SKIP_LINE = "# praisonai: skip=true"
SKIP_RE = re.compile(r"^#\s*praisonai:\s*skip\s*=", re.MULTILINE)
@@ -55,13 +56,29 @@ def add_skip(path: Path) -> bool:
text = path.read_text(encoding="utf-8", errors="ignore")
if SKIP_RE.search("\n".join(text.splitlines()[:30])):
return False
new_text = SKIP_LINE + "\n" + text.lstrip("\ufeff")
text = text.lstrip("\ufeff")
lines = text.splitlines(keepends=True)
# Preserve a shebang on the first line so direct execution still works.
if lines and lines[0].startswith("#!"):
lines.insert(1, SKIP_LINE + "\n")
new_text = "".join(lines)
else:
new_text = SKIP_LINE + "\n" + text
path.write_text(new_text, encoding="utf-8")
return True
def main() -> None:
report = json.loads(REPORT.read_text(encoding="utf-8"))
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"report",
nargs="?",
type=Path,
default=DEFAULT_REPORT,
help="Path to the E2E report.json (defaults to the audit report path).",
)
args = parser.parse_args()
report = json.loads(args.report.read_text(encoding="utf-8"))
targets: set[str] = set()
for it in report.get("results", []):
if it.get("status") in ("failed", "timeout"):
+26 -26
View File
@@ -18,39 +18,39 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 46 | 14663 |
| ✅ Agent | 21 | 45 | 14056 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2312 |
| ✅ Approval | 1 | 5 | 2286 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 10 | 2880 |
| ✅ Auto Generation | 5 | 10 | 2868 |
| ✅ Autonomy | 3 | 7 | 2707 |
| ✅ Bots | 7 | 31 | 12353 |
| ✅ Bots | 7 | 28 | 11165 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 119 | 34537 |
| ✅ CLI | 5 | 119 | 33595 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 4031 |
| ✅ Code Execution | 2 | 13 | 3907 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 7 | 2773 |
| ✅ Context Management | 16 | 34 | 13206 |
| ✅ Configuration | 3 | 7 | 2772 |
| ✅ Context Management | 16 | 34 | 13204 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Evaluation | 1 | 7 | 2902 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1560 |
| ✅ Execution | 3 | 4 | 1528 |
| ✅ Failover | 2 | 1 | 400 |
| ✅ Files | 2 | 6 | 2066 |
| ✅ Files | 2 | 6 | 1964 |
| ✅ Flow | 1 | 3 | 777 |
| ✅ Gateway | 7 | 59 | 21584 |
| ✅ Gateway | 7 | 52 | 18862 |
| ✅ Guardrails | 4 | 4 | 1673 |
| ✅ Handoffs | 11 | 6 | 2454 |
| ✅ Hooks | 2 | 9 | 4128 |
| ✅ Hooks | 2 | 9 | 3985 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 13 | 4160 |
| ✅ LLM | 3 | 14 | 4864 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ LLM | 3 | 14 | 4767 |
| ✅ Loops | 4 | 4 | 1202 |
| ✅ MCP | 1 | 58 | 13322 |
| ✅ Memory | 6 | 17 | 6635 |
| ✅ OCR | 2 | 1 | 237 |
@@ -59,23 +59,23 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Output | 3 | 5 | 1158 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1545 |
| ✅ Plugins | 8 | 3 | 1900 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Providers | 1 | 55 | 7502 |
| ✅ Plugins | 8 | 3 | 1879 |
| ✅ Prompts | 2 | 8 | 1726 |
| ✅ Providers | 1 | 55 | 7459 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 2936 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1044 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 6 | 2766 |
| ✅ Security | 1 | 3 | 2970 |
| ✅ Sessions | 4 | 13 | 5302 |
| ✅ Skills | 6 | 13 | 4998 |
| ✅ Tasks | 2 | 6 | 2809 |
| ✅ Sandbox | 5 | 6 | 2700 |
| ✅ Security | 1 | 3 | 2908 |
| ✅ Sessions | 4 | 13 | 5286 |
| ✅ Skills | 6 | 13 | 4946 |
| ✅ Tasks | 2 | 6 | 2743 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 133 | 38149 |
| ✅ Templates | 1 | 8 | 1657 |
| ✅ Tools | 12 | 132 | 37592 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1193 |
| ✅ Video | 2 | 6 | 649 |
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
These docs exist but don't match any implemented feature category:
- ℹ️ Database (41 docs, 6478 lines)
- ℹ️ Database (40 docs, 6184 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -258,7 +258,6 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RunOutcome': ('praisonaiagents.agent.run_outcome', 'RunOutcome'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
@@ -839,7 +838,6 @@ __all__ = [
# Core classes - the essentials
'Agent',
'RunOutcome',
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
@@ -4,6 +4,7 @@ This module consolidates all logging configuration in one place to avoid duplica
"""
import os
import json
import logging
from typing import Any, Dict, List, Optional, Union
@@ -195,9 +196,6 @@ class StructuredFormatter(logging.Formatter):
extra = {k: v for k, v in record.extra_data.items() if k not in self._STANDARD_FIELDS}
log_data.update(extra)
# Deferred: json is only needed for opt-in structured logging
import json
return json.dumps(log_data)
@@ -37,15 +37,6 @@ def __getattr__(name):
from .interrupt import InterruptController
_lazy_cache[name] = InterruptController
return InterruptController
if name in ('RunOutcome', 'TerminalReason'):
from . import run_outcome as _run_outcome_module
value = getattr(_run_outcome_module, name)
_lazy_cache[name] = value
return value
if name == 'prompt_prefix_signature':
from .prompt_cache import prompt_prefix_signature
_lazy_cache[name] = prompt_prefix_signature
return prompt_prefix_signature
# Specialized agents - lazy loaded (import rich)
if name == 'ImageAgent':
@@ -224,9 +215,6 @@ __all__ = [
'Heartbeat',
'HeartbeatConfig',
'InterruptController',
'RunOutcome',
'TerminalReason',
'prompt_prefix_signature',
'ImageAgent',
'VideoAgent',
'VideoConfig',
@@ -20,7 +20,7 @@ from .async_memory_mixin import AsyncMemoryMixin
from .tool_execution import ToolExecutionMixin, BackoffPolicy
from .chat_handler import ChatHandlerMixin
from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState, DualLock
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.
@@ -2116,12 +2116,6 @@ Your Goal: {self.goal}
# Store tool retry policy for tool execution with exponential backoff
self._tool_retry_policy = _tool_config.retry_policy if _tool_config else None
# Whether undeclared tool names may resolve via the process-global @tool
# registry. Default False keeps the agent scoped to its own tools=[...].
self._allow_global_tools = (
_tool_config.allow_global_tools if _tool_config else False
)
# Retry configuration with jittered exponential backoff.
# Default (retry is None) applies RetryBackoffConfig() so the native
@@ -2230,10 +2224,7 @@ Your Goal: {self.goal}
# Per-turn tool-name buffer feeding the self-improve review policy.
# Populated in _execute_tool_with_context, reset each chat turn, and
# read by _trigger_after_agent_hook when tools_used is not passed.
# Guarded by a DualLock (like chat_history) so concurrent chat()/achat()
# turns on the same Agent instance don't corrupt each other's buffer.
self._turn_tools_used = []
self._turn_tools_lock = DualLock()
# Database persistence (lazy - no imports until used)
self._db = db
@@ -3201,44 +3192,7 @@ Summary:"""
# ================================================================
# Filesystem tracking convenience methods (powered by FileSnapshot)
# ================================================================
def set_snapshot_root(self, project_path: str) -> bool:
"""Root filesystem change-tracking at ``project_path``.
Bots/gateway resolve file tools against a per-chat ``Workspace`` and
attach it *after* construction, but the FileSnapshot backing
:meth:`undo`/:meth:`redo`/:meth:`diff` was created at ``__init__`` time
rooted at ``os.getcwd()``. Without this, ``/undo`` tracks the wrong
directory (never where the tools actually wrote). Call this once after
the workspace is known so change tracking follows the tools.
Rooting at a new directory clears the undo/redo stacks (they belong to
the previous root). A no-op when the root is unchanged. Returns ``True``
when a snapshot manager is rooted at ``project_path``.
"""
import os
new_root = os.path.abspath(str(project_path))
current = self._file_snapshot
if current is not None and getattr(current, "project_path", None) == new_root:
return True
try:
from ..snapshot import FileSnapshot
snapshot_dir = None
cfg = getattr(self, "autonomy_config", None)
if isinstance(cfg, dict):
snapshot_dir = cfg.get("snapshot_dir")
self._file_snapshot = FileSnapshot(
project_path=new_root,
snapshot_dir=snapshot_dir,
)
with self._snapshot_lock:
self._snapshot_stack = []
self._redo_stack = []
return True
except Exception as e: # pragma: no cover - git may be unavailable
logger.debug(f"Re-rooting FileSnapshot failed: {e}")
return False
def undo(self) -> bool:
"""Undo the last set of file changes.
@@ -6163,12 +6117,6 @@ Answer:"""
images=images,
system_prompt=system_prompt
)
await self._emit_cli_backend_hook(
backend=backend,
session_id=session_id,
result=result,
)
# Check for CLI backend errors
if result is None:
@@ -6197,55 +6145,10 @@ Answer:"""
return result.content if result else None
except Exception as e:
await self._emit_cli_backend_hook(
backend=backend,
session_id=session_id,
result=None,
error=str(e),
)
raise RuntimeError(
f"CLI backend execution failed for agent={self.display_name!r}: {e}"
) from e
async def _emit_cli_backend_hook(
self,
*,
backend: Any,
session_id: Optional[str],
result: Any,
error: Optional[str] = None,
) -> None:
"""Emit the CLI_BACKEND_EXECUTE hook (no-op when no listener is registered).
Fires on both success and failure so subprocess startup errors and
timeouts remain observable. Prompt-bearing argv values are redacted at
the payload serialization boundary.
"""
from ..hooks.types import HookEvent
if not self._hook_runner.registry.has_hooks(HookEvent.CLI_BACKEND_EXECUTE):
return
from ..hooks.events import CliBackendExecuteInput
from ..cli_backend.debug import backend_label
metadata = getattr(result, "metadata", None) or {}
hook_input = CliBackendExecuteInput(
session_id=session_id,
cwd=os.getcwd(),
event_name=HookEvent.CLI_BACKEND_EXECUTE.value,
timestamp=str(time.time()),
agent_name=self.display_name,
backend=backend_label(backend),
command=metadata.get("command"),
content=getattr(result, "content", None),
error=error if error is not None else getattr(result, "error", None),
)
await self._hook_runner.execute(
HookEvent.CLI_BACKEND_EXECUTE,
hook_input,
)
# -------------------------------------------------------------------------
# Resource Lifecycle Management
# -------------------------------------------------------------------------
@@ -7,6 +7,7 @@ async capabilities following the AsyncMemoryProtocol.
"""
import asyncio
import logging
from typing import List, Dict, Any, Optional, Union
from praisonaiagents._logging import get_logger
from ..memory.protocols import AsyncMemoryProtocol
@@ -34,6 +34,7 @@ Usage:
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Set
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -625,26 +625,11 @@ Your Goal: {self.goal}"""
formatted_tools.extend(openai_tools)
elif openai_tools is not None:
formatted_tools.append(openai_tools)
# Handle BaseTool-style instances (ClarifyTool, etc.)
elif getattr(tool, "name", None) and hasattr(tool, "run"):
tool_def = self._generate_tool_definition(tool.name)
if tool_def:
formatted_tools.append(tool_def)
else:
logging.warning(
"Could not generate definition for tool: %s", tool.name
)
# Handle callable functions
elif callable(tool):
fname = getattr(tool, "name", None) or getattr(tool, "__name__", None)
if not fname:
logging.warning("Tool %r not recognized (no name)", tool)
continue
tool_def = self._generate_tool_definition(fname)
tool_def = self._generate_tool_definition(tool.__name__)
if tool_def:
formatted_tools.append(tool_def)
else:
logging.warning(f"Could not generate definition for tool: {fname}")
else:
logging.warning(f"Tool {tool} not recognized")
@@ -2462,7 +2447,8 @@ Your Goal: {self.goal}"""
"""Internal chat implementation (extracted for trace wrapping)."""
# Reset the per-turn tool buffer so the self-improve review policy only
# sees tools used in this turn (not during a nested skill-review turn).
self._reset_turn_tools()
if not getattr(self, "_in_skill_review", False):
self._turn_tools_used = []
# Apply rate limiter if configured (before any LLM call)
if self._rate_limiter is not None:
self._rate_limiter.acquire()
@@ -3063,7 +3049,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""Internal async chat implementation (extracted for trace wrapping)."""
# Reset the per-turn tool buffer so the self-improve review policy only
# sees tools used in this turn (not during a nested skill-review turn).
self._reset_turn_tools()
if not getattr(self, "_in_skill_review", False):
self._turn_tools_used = []
# C2 - cooperative cancellation: abort early if a pre-set token is given
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
@@ -3731,20 +3718,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Failed to parse tool arguments as JSON: {json_error}")
arguments = {}
# Find the matching tool by comparing every supported identifier:
# __name__ (plain callables), .name (BaseTool instances like
# BrowserBaseTool, or aliased FunctionTools), or the class name.
# Compare all candidates (not short-circuit) so an aliased .name
# that differs from __name__ still resolves and BaseTool
# subclasses without __name__ do not crash dispatch.
tool = next(
(t for t in tools if function_name in (
getattr(t, "__name__", None),
getattr(t, "name", None),
type(t).__name__,
)),
None,
)
# Find the matching tool
tool = next((t for t in tools if t.__name__ == function_name), None)
if not tool:
_get_display_functions()['display_error'](f"Tool {function_name} not found")
continue
@@ -4444,64 +4419,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
return False
def _emit_retry_stream_event(self, *, attempt, max_attempts, delay, reason):
"""Emit a ``StreamEventType.RETRY`` event during backoff.
Lets consumers (e.g. the CLI stream-json bridge) render a live
"retrying in Ns (attempt k/N)" status instead of appearing hung.
Guarded by ``has_callbacks`` so it stays zero-overhead when nothing is
listening, and never raises into the retry loop.
"""
emitter = getattr(self, 'stream_emitter', None)
if emitter is None or not getattr(emitter, 'has_callbacks', False):
return
try:
from ..streaming.events import StreamEvent, StreamEventType
emitter.emit(self._build_retry_stream_event(
StreamEvent, StreamEventType,
attempt=attempt, max_attempts=max_attempts, delay=delay, reason=reason,
))
except Exception as _re:
logger.debug(f"Failed to emit RETRY stream event: {_re}")
async def _aemit_retry_stream_event(self, *, attempt, max_attempts, delay, reason):
"""Async counterpart of ``_emit_retry_stream_event``.
Uses ``emit_async`` so consumers registered via the public
``add_async_callback()`` API also receive RETRY events; ``emit`` alone
only visits synchronous callbacks. Same zero-overhead guard and
never-raise contract as the sync path.
"""
emitter = getattr(self, 'stream_emitter', None)
if emitter is None or not getattr(emitter, 'has_callbacks', False):
return
try:
from ..streaming.events import StreamEvent, StreamEventType
event = self._build_retry_stream_event(
StreamEvent, StreamEventType,
attempt=attempt, max_attempts=max_attempts, delay=delay, reason=reason,
)
emit_async = getattr(emitter, 'emit_async', None)
if emit_async is not None:
await emit_async(event)
else:
emitter.emit(event)
except Exception as _re:
logger.debug(f"Failed to emit RETRY stream event: {_re}")
def _build_retry_stream_event(self, StreamEvent, StreamEventType, *, attempt, max_attempts, delay, reason):
"""Construct the RETRY ``StreamEvent`` shared by the sync/async emitters."""
return StreamEvent(
type=StreamEventType.RETRY,
metadata={
"attempt": attempt,
"max_attempts": max_attempts,
"delay": delay,
"reason": reason,
},
agent_id=getattr(self, 'name', None),
)
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
@@ -4563,17 +4480,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
self._hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input)
# Surface the retry as a first-class stream event so CLI/UI
# consumers can show a live "retrying in Ns" status instead of
# appearing hung. Guarded so it is zero-overhead when nothing
# is listening.
self._emit_retry_stream_event(
attempt=attempt + 1,
max_attempts=max_attempts,
delay=delay,
reason=str(e),
)
# Log retry attempt (buffered to avoid spam during transient failures)
logger.debug(f"[{self.name}] Retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
@@ -4655,16 +4561,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
await self._hook_runner.execute_async(HookEvent.ON_RETRY, retry_input)
# Surface a streaming RETRY event (see sync path for rationale).
# Use the async emitter so async-only consumers registered via
# add_async_callback() also receive the event.
await self._aemit_retry_stream_event(
attempt=attempt + 1,
max_attempts=max_attempts,
delay=delay,
reason=str(e),
)
# Log retry attempt
logger.debug(f"[{self.name}] Async retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
@@ -12,6 +12,7 @@ Usage::
"""
import json
import logging
from praisonaiagents._logging import get_logger
import os
import time
@@ -34,6 +34,7 @@ Example:
"""
import os
import logging
from praisonaiagents._logging import get_logger
import time
import asyncio
@@ -125,30 +125,16 @@ class ExecutionMixin:
Args:
prompt: The input prompt to process
**kwargs: Additional arguments passed to achat():
- return_outcome (bool): If True, return a canonical
``RunOutcome`` (completed | hard_timeout | cancelled |
aborted | failed) instead of raising on error/timeout/
cancellation. Default: False.
- timeout (float): When used with ``return_outcome=True``,
a hard timeout budget for the run.
**kwargs: Additional arguments passed to achat()
Returns:
The agent's response as a string, or AutonomyResult if autonomy
enabled, or a ``RunOutcome`` when ``return_outcome=True``.
The agent's response as a string, or AutonomyResult if autonomy enabled
Note:
If autonomy=True was set on the agent, astart() automatically uses
the autonomous loop (run_autonomous_async) instead of single-turn chat.
"""
import sys
return_outcome = kwargs.pop('return_outcome', False)
if return_outcome:
outcome_timeout = kwargs.pop('timeout', None)
return await self._astart_with_outcome(
prompt, outcome_timeout, **kwargs
)
# ─────────────────────────────────────────────────────────────────────
# UNIFIED AUTONOMY API: If autonomy is enabled, route to run_autonomous_async
@@ -210,57 +196,6 @@ class ExecutionMixin:
kwargs['stream'] = stream_requested
return await self.achat(prompt, **kwargs)
async def _astart_with_outcome(self, prompt, timeout=None, **kwargs):
"""Run astart() and normalise its result/exception into a RunOutcome.
Optionally enforces a hard run-level timeout budget. The budget is the
*authoritative* source of ``hard_timeout``: when our own
``asyncio.wait_for`` fires we return ``hard_timeout`` directly, so a
nested operation timeout (which surfaces as a generic
``asyncio.TimeoutError`` from inside the run) is never misreported as a
run-budget timeout — it falls through to ``failed`` via
``RunOutcome.from_exception``.
External ``asyncio.CancelledError`` (e.g. the enclosing task being
cancelled during host shutdown) is re-raised, not swallowed, so
cooperative cancellation semantics are honoured.
"""
import asyncio
from .run_outcome import RunOutcome
enforce_budget = timeout is not None and timeout > 0
# Enforce the run budget with an explicit deadline on a task we own, so
# that only *our* budget expiry maps to hard_timeout. This deliberately
# avoids ``asyncio.wait_for`` catching a bare inner ``TimeoutError``:
# a nested operation timeout raised inside the run must fall through to
# ``failed`` rather than be misreported as a run-budget ``hard_timeout``.
try:
if enforce_budget:
task = asyncio.ensure_future(self.astart(prompt, **kwargs))
try:
done, _ = await asyncio.wait({task}, timeout=timeout)
except asyncio.CancelledError:
# Enclosing task cancelled (e.g. host shutdown): cancel the
# child and propagate so cooperative cancellation is honoured.
task.cancel()
raise
if task not in done:
# Our budget expired first: authoritative, sticky hard_timeout.
task.cancel()
return RunOutcome(reason="hard_timeout")
result = task.result()
else:
result = await self.astart(prompt, **kwargs)
except asyncio.CancelledError:
# External cancellation of the awaiting task: honour asyncio
# cancellation / host shutdown by propagating instead of converting
# it into a benign RunOutcome.
raise
except Exception as exc: # noqa: BLE001 - normalised into outcome
return RunOutcome.from_exception(exc)
return RunOutcome.completed(output=str(result) if result is not None else None)
def run(self, prompt: str, **kwargs: Any) -> Optional[str]:
"""Execute agent silently and return structured result.
@@ -273,14 +208,9 @@ class ExecutionMixin:
**kwargs: Additional arguments:
- stream (bool): Force streaming if True. Default: False
- output (str): Output preset override (rarely needed)
- return_outcome (bool): If True, return a canonical
``RunOutcome`` describing how the run ended
(completed | hard_timeout | cancelled | aborted | failed)
instead of raising on error. Default: False.
Returns:
The agent's response as a string, or a ``RunOutcome`` when
``return_outcome=True``.
The agent's response as a string
Example:
```python
@@ -297,14 +227,8 @@ class ExecutionMixin:
- Background processing
- API endpoints
"""
return_outcome = kwargs.pop('return_outcome', False)
# Check if external managed backend is configured
if hasattr(self, 'backend') and self.backend is not None:
if return_outcome:
return self._run_with_outcome(
lambda: self._delegate_to_backend(prompt, **kwargs)
)
return self._delegate_to_backend(prompt, **kwargs)
# Production defaults: no streaming, no display
@@ -318,34 +242,17 @@ class ExecutionMixin:
# Load history context
self._load_history_context()
def _execute():
if self.planning:
_result = self._start_with_planning(prompt, **kwargs)
else:
_result = self.chat(prompt, **kwargs)
# Auto-save session if enabled
self._auto_save_session()
return _result
if return_outcome:
return self._run_with_outcome(_execute)
return _execute()
def _run_with_outcome(self, executor):
"""Run ``executor`` and normalise its result/exception into a RunOutcome.
Keeps the canonical terminal-outcome logic in one place so callers get
a single, closed description of how the run ended instead of inferring
it from exception identity.
"""
from .run_outcome import RunOutcome
try:
result = executor()
except BaseException as exc: # noqa: BLE001 - normalised into outcome
return RunOutcome.from_exception(exc)
return RunOutcome.completed(output=str(result) if result is not None else None)
# Check if planning mode is enabled
if self.planning:
result = self._start_with_planning(prompt, **kwargs)
else:
result = self.chat(prompt, **kwargs)
# Auto-save session if enabled
self._auto_save_session()
return result
def _delegate_to_backend(self, prompt: str, **kwargs) -> Optional[str]:
"""Delegate execution to external managed backend (e.g., ManagedAgentIntegration).
@@ -1239,9 +1146,13 @@ Write the complete compiled report:"""
# Record the tool name for this turn so the self-improve review policy
# sees async tool usage too (issue #3037). Mirrors the sync path in
# _execute_tool_with_context; skipped during a guarded review turn so
# the review's own calls are not tracked and cannot recurse. Locked so
# concurrent turns on the same Agent don't corrupt the buffer (#3307).
self._record_turn_tool(function_name)
# the review's own calls are not tracked and cannot recurse.
if not getattr(self, "_in_skill_review", False):
turn_tools = getattr(self, "_turn_tools_used", None)
if turn_tools is None:
self._turn_tools_used = []
turn_tools = self._turn_tools_used
turn_tools.append(function_name)
# Enforce BEFORE_TOOL/AFTER_TOOL security hooks for every async caller,
# mirroring the sync execute_tool path in tool_execution.py. Without
@@ -1458,22 +1369,11 @@ Write the complete compiled report:"""
return policy_result # Error dict
_, arguments = policy_result
# Try to find the function in the override tools list first, then agent's tools list.
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
# aliased decorated tools), plain callable ``__name__``, or class name so async
# dispatch matches the robust sync resolution in _execute_tool_impl.
# Try to find the function in the override tools list first, then agent's tools list
func = None
tools_to_search = tools_override if tools_override is not None else self.tools
from ..tools.base import BaseTool
for tool in tools_to_search:
if isinstance(tool, BaseTool) and getattr(tool, 'name', None) == function_name:
func = tool
break
if hasattr(tool, 'name') and getattr(tool, 'name', None) == function_name:
func = tool
break
if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \
(inspect.isclass(tool) and tool.__name__ == function_name):
if (callable(tool) and getattr(tool, '__name__', '') == function_name):
func = tool
break
@@ -1482,18 +1382,15 @@ Write the complete compiled report:"""
return {"error": f"Function {function_name} not found in tools"}
try:
# BaseTool instances (plugin system, e.g. BrowserBaseTool) are not
# directly callable — dispatch to their .run() method like the sync path.
call_target = func.run if isinstance(func, BaseTool) else func
if inspect.iscoroutinefunction(call_target):
if inspect.iscoroutinefunction(func):
logging.debug(f"Executing async function: {function_name}")
result = await call_target(**arguments)
result = await func(**arguments)
else:
logging.debug(f"Executing sync function in executor: {function_name}")
loop = asyncio.get_running_loop()
from ..trace.context_events import copy_context_to_callable
result = await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**arguments))
None, copy_context_to_callable(lambda: func(**arguments))
)
# Ensure result is JSON serializable
@@ -1577,166 +1474,157 @@ Write the complete compiled report:"""
should_start = False
with _server_lock:
# Initialize port-specific collections if needed (once per port)
# Initialize port-specific collections if needed
if port not in _registered_agents:
_registered_agents[port] = {}
# Initialize shared FastAPI app if not already created for this port
if _shared_apps.get(port) is None:
_shared_apps[port] = FastAPI(
title=f"PraisonAI Agents API (Port {port})",
description="API for interacting with PraisonAI Agents"
)
# Add a root endpoint with a welcome message
@_shared_apps[port].get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": list(_registered_agents[port].keys())
}
# Add healthcheck endpoint
@_shared_apps[port].get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": list(_registered_agents[port].keys())
}
# The path registration below must run on EVERY call, not just when the
# port is new, so multiple agents can share a single port.
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Check if path is already registered for this port
if path in _registered_agents[port]:
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
# Use a modified path to avoid conflicts
original_path = path
path = f"{path}_{self.agent_id[:6]}"
logging.warning(f"Using '{path}' instead of '{original_path}'")
print(f"🔄 Using '{path}' instead")
# Register the agent to this path
_registered_agents[port][path] = self.agent_id
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
try:
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
# Initialize shared FastAPI app if not already created for this port
if _shared_apps.get(port) is None:
_shared_apps[port] = FastAPI(
title=f"PraisonAI Agents API (Port {port})",
description="API for interacting with PraisonAI Agents"
)
# Invalidate the cached OpenAPI schema so routes registered by later
# shared-port launch() calls still show up in /openapi.json and /docs.
# FastAPI caches app.openapi_schema on first access and does not
# regenerate it when new routes are added afterwards.
_shared_apps[port].openapi_schema = None
# Add a root endpoint with a welcome message
@_shared_apps[port].get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": list(_registered_agents[port].keys())
}
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# Add healthcheck endpoint
@_shared_apps[port].get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": list(_registered_agents[port].keys())
}
# Check and mark server as started atomically to prevent race conditions
should_start = not _server_started.get(port, False)
if should_start:
_server_started[port] = True
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Server start/wait outside the lock to avoid holding it during sleep
if should_start:
# Start the server in a separate thread
def run_server():
try:
print(f"✅ FastAPI server started at http://{host}:{port}")
print(f"📚 API documentation available at http://{host}:{port}/docs")
print(f"🔌 Available endpoints: {', '.join(list(_registered_agents[port].keys()))}")
uvicorn.run(_shared_apps[port], host=host, port=port, log_level="debug" if debug else "info")
except Exception as e:
logging.error(f"Error starting server: {str(e)}", exc_info=True)
print(f"❌ Error starting server: {str(e)}")
# Check if path is already registered for this port
if path in _registered_agents[port]:
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
# Use a modified path to avoid conflicts
original_path = path
path = f"{path}_{self.agent_id[:6]}"
logging.warning(f"Using '{path}' instead of '{original_path}'")
print(f"🔄 Using '{path}' instead")
# Run server in a background thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Register the agent to this path
_registered_agents[port][path] = self.agent_id
# Wait for a moment to allow the server to start and register endpoints
self._safe_sleep(0.5)
else:
# If server is already running, wait a moment to make sure the endpoint is registered
self._safe_sleep(0.1)
print(f"🔌 Available endpoints on port {port}: {', '.join(list(_registered_agents[port].keys()))}")
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
# Get the stack frame to check if this is the last launch() call in the script
import inspect
stack = inspect.stack()
# If this is called from a Python script (not interactive), try to detect if it's the last launch call
if len(stack) > 1 and stack[1].filename.endswith('.py'):
caller_frame = stack[1]
caller_line = caller_frame.lineno
try:
# Read the file to check if there are more launch calls after this one
with open(caller_frame.filename, 'r') as f:
lines = f.readlines()
# Check if there are more launch() calls after the current line
has_more_launches = False
for line_content in lines[caller_line:]: # renamed line to line_content
if '.launch(' in line_content and not line_content.strip().startswith('#'):
has_more_launches = True
break
# If this is the last launch call, block the main thread
if not has_more_launches:
try:
print("\nAll agents registered for HTTP mode. Press Ctrl+C to stop the servers.")
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
)
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# Check and mark server as started atomically to prevent race conditions
should_start = not _server_started.get(port, False)
if should_start:
_server_started[port] = True
# Server start/wait outside the lock to avoid holding it during sleep
if should_start:
# Start the server in a separate thread
def run_server():
try:
print(f"✅ FastAPI server started at http://{host}:{port}")
print(f"📚 API documentation available at http://{host}:{port}/docs")
print(f"🔌 Available endpoints: {', '.join(list(_registered_agents[port].keys()))}")
uvicorn.run(_shared_apps[port], host=host, port=port, log_level="debug" if debug else "info")
except Exception as e:
logging.error(f"Error starting server: {str(e)}", exc_info=True)
print(f"❌ Error starting server: {str(e)}")
# Run server in a background thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for a moment to allow the server to start and register endpoints
self._safe_sleep(0.5)
else:
# If server is already running, wait a moment to make sure the endpoint is registered
self._safe_sleep(0.1)
print(f"🔌 Available endpoints on port {port}: {', '.join(list(_registered_agents[port].keys()))}")
# Get the stack frame to check if this is the last launch() call in the script
import inspect
stack = inspect.stack()
# If this is called from a Python script (not interactive), try to detect if it's the last launch call
if len(stack) > 1 and stack[1].filename.endswith('.py'):
caller_frame = stack[1]
caller_line = caller_frame.lineno
try:
# Read the file to check if there are more launch calls after this one
with open(caller_frame.filename, 'r') as f:
lines = f.readlines()
# Check if there are more launch() calls after the current line
has_more_launches = False
for line_content in lines[caller_line:]: # renamed line to line_content
if '.launch(' in line_content and not line_content.strip().startswith('#'):
has_more_launches = True
break
# If this is the last launch call, block the main thread
if not has_more_launches:
try:
print("\nAll agents registered for HTTP mode. Press Ctrl+C to stop the servers.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
except Exception as e:
# If something goes wrong with detection, block anyway to be safe
logging.error(f"Error in launch detection: {e}")
try:
print("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
except Exception as e:
# If something goes wrong with detection, block anyway to be safe
logging.error(f"Error in launch detection: {e}")
try:
print("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
return None
return None
def _launch_mcp_server(self, path: str, port: int, host: str, debug: bool):
"""
@@ -1752,7 +1640,8 @@ Write the complete compiled report:"""
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.requests import Request
from starlette.routing import Mount, Route
import threading
import time
import asyncio
@@ -1779,11 +1668,31 @@ Write the complete compiled report:"""
except Exception as e:
return f"Error executing task: {str(e)}"
# Create and run MCP server
# Create and run MCP server using the low-level SSE transport
# pattern (FastMCP has no create_app(); this mirrors the working
# implementation in praisonaiagents/mcp/mcp_server.py).
base_path = (path or "/mcp").rstrip("/") or "/mcp"
transport = SseServerTransport(f"{base_path}/sse")
if not base_path.startswith("/"):
base_path = f"/{base_path}"
sse_path = f"{base_path}/sse"
messages_path = f"{base_path}/messages/"
transport = SseServerTransport(messages_path)
async def handle_sse(request: Request):
async with transport.connect_sse(
request.scope, request.receive, request._send
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options()
)
starlette_app = Starlette(
routes=[Mount(base_path, mcp.sse_app())]
routes=[
Route(sse_path, endpoint=handle_sse),
Mount(messages_path, app=transport.handle_post_message),
]
)
def run_mcp_server():
@@ -14,9 +14,9 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
from dataclasses import dataclass, field
from enum import Enum
import inspect
import logging
from praisonaiagents._logging import get_logger
import asyncio
import contextvars
import threading
import time
import json
@@ -163,50 +163,33 @@ from ..errors import (
HandoffValidationError
)
# Per-task/per-thread storage for tracking handoff chains.
#
# ``contextvars.ContextVar`` isolates the chain per :class:`asyncio.Task`
# (and per OS thread), unlike ``threading.local()`` which shares one list
# across every coroutine running on the same event-loop thread. Concurrent
# async handoffs (``asyncio.gather``, a server handling parallel requests, or
# any ``max_concurrent`` workflow) would otherwise push/pop into the same list
# and corrupt each other's cycle/depth state.
_handoff_chain_var: "contextvars.ContextVar[Optional[List[str]]]" = contextvars.ContextVar(
"handoff_chain", default=None
)
# Thread-local storage for tracking handoff chains
_handoff_context = threading.local()
def _get_handoff_chain() -> List[str]:
"""Get current handoff chain for this task/thread."""
chain = _handoff_chain_var.get()
if chain is None:
chain = []
_handoff_chain_var.set(chain)
return chain
"""Get current handoff chain from thread-local storage."""
if not hasattr(_handoff_context, 'chain'):
_handoff_context.chain = []
return _handoff_context.chain
def _get_handoff_depth() -> int:
"""Get current handoff depth."""
return len(_get_handoff_chain())
def _push_handoff(agent_name: str) -> None:
"""Push agent to handoff chain.
Uses copy-on-write so a push made inside a child task does not leak back
into the parent task's chain once the child completes.
"""
chain = list(_get_handoff_chain())
"""Push agent to handoff chain."""
chain = _get_handoff_chain()
chain.append(agent_name)
_handoff_chain_var.set(chain)
def _pop_handoff() -> Optional[str]:
"""Pop agent from handoff chain."""
chain = list(_get_handoff_chain())
popped = chain.pop() if chain else None
_handoff_chain_var.set(chain)
return popped
chain = _get_handoff_chain()
return chain.pop() if chain else None
def _clear_handoff_chain() -> None:
"""Clear the handoff chain."""
_handoff_chain_var.set([])
if hasattr(_handoff_context, 'chain'):
_handoff_context.chain = []
@dataclass
class HandoffInputData:
@@ -13,6 +13,7 @@ Usage::
"""
import asyncio
import logging
from praisonaiagents._logging import get_logger
import threading
import time
@@ -22,6 +22,7 @@ from __future__ import annotations
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -8,6 +8,7 @@ Thread-safe implementation for concurrent access.
import threading
import heapq
import time
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, Any, List, Tuple
from dataclasses import dataclass, field
@@ -1,138 +0,0 @@
"""Prompt-cache-stability contract for the Agent turn (Issue #3352).
Provider prompt caching (Anthropic/OpenAI) only hits when the *leading* bytes
of a request are byte-identical to the previous request. For a long-lived
conversation the cached prefix — the system instructions, the serialised tool
schemas, and the model identity — is the dominant cost and latency lever.
Gateways run many users on a single shared ``Agent`` instance and swap
``tools``/``llm`` in and out per turn (per-route tool scoping, per-user
``/model`` override). Those swaps silently change the cached prefix and the
provider cache misses with zero operator visibility.
``prompt_prefix_signature`` gives callers a single, cheap, deterministic
fingerprint of exactly the cache-relevant inputs — model, the sorted set of
tool names, and a fingerprint of the system instructions — and *nothing*
volatile per-turn (arrival time, routing facts, memory/RAG recall). When the
signature is unchanged across turns the prefix is guaranteed stable, so the
provider cache keeps hitting; when it changes, that is the one auditable point
at which cache warmth is knowingly sacrificed.
"""
from __future__ import annotations
import hashlib
from typing import Any
def _tool_name(tool: Any) -> str:
"""Best-effort stable name for a tool entry.
Tools may be plain callables, dicts holding an OpenAI-style function
schema, or objects exposing ``__name__``/``name``. The name identifies the
schema slot in the serialised tool block.
"""
if isinstance(tool, dict):
fn = tool.get("function")
if isinstance(fn, dict) and fn.get("name"):
return str(fn["name"])
for key in ("name", "type"):
if tool.get(key):
return str(tool[key])
return str(sorted(tool.keys()))
for attr in ("__name__", "name"):
value = getattr(tool, attr, None)
if value:
return str(value)
return str(tool)
def _tool_fingerprint(tool: Any) -> str:
"""Best-effort stable fingerprint of a tool's *provider-visible* schema.
The serialised tool block sent to the provider is not just the tool name —
it is the full function schema (name + description + parameters). A change
to a tool's description, parameters, or required fields *without* a rename
changes those bytes and therefore invalidates the prompt cache, so the
fingerprint must reflect the whole schema, not only the name (Issue #3352
review). Falls back to the name when no richer schema is exposed.
"""
name = _tool_name(tool)
# Dict tools already carry an OpenAI-style function schema — fingerprint the
# description + parameters alongside the name so a same-name schema edit is
# detected. ``json.dumps(sort_keys=True)`` makes the render deterministic.
if isinstance(tool, dict):
fn = tool.get("function")
schema = fn if isinstance(fn, dict) else tool
desc = schema.get("description") if isinstance(schema, dict) else None
params = schema.get("parameters") if isinstance(schema, dict) else None
try:
import json
return "\x01".join(
(
name,
str(desc or ""),
json.dumps(params or {}, sort_keys=True, default=str),
)
)
except Exception: # pragma: no cover — defensive
return name
# Callable/object tools: fold in the docstring (the description the schema
# generator emits) so an edited tool doc flips the signature too.
doc = getattr(tool, "__doc__", None)
if doc:
return f"{name}\x01{doc}"
return name
def prompt_prefix_signature(agent: Any) -> str:
"""Return a sha256 signature of an agent's cache-relevant prompt prefix.
The signature is computed over, and only over:
* the model identity (``agent.llm``),
* the sorted set of tool *schema fingerprints* (name + description +
parameters, ``agent.tools``) — so a same-name schema edit is detected,
* a fingerprint of the system instructions (``agent.instructions``).
Volatile per-turn data is intentionally excluded so that an unchanged
route/model keeps a byte-identical prefix and the provider cache keeps
hitting. Callers compare the signature turn-over-turn: an unchanged value
means the cached prefix is reused; a changed value is the single auditable
point of prompt-cache invalidation.
Best-effort and never raises: any attribute access failure degrades the
corresponding component to an empty string rather than breaking the turn.
"""
try:
llm = getattr(agent, "llm", None)
model = llm if isinstance(llm, str) else getattr(llm, "model", None) or str(llm)
except Exception:
model = ""
try:
tools = getattr(agent, "tools", None) or []
tool_fps = sorted(_tool_fingerprint(t) for t in tools)
except Exception:
tool_fps = []
try:
instructions = getattr(agent, "instructions", None) or ""
instructions = str(instructions)
except Exception:
instructions = ""
hasher = hashlib.sha256()
hasher.update(b"model\x00")
hasher.update(str(model).encode("utf-8", "replace"))
hasher.update(b"\x00tools\x00")
hasher.update("\x00".join(tool_fps).encode("utf-8", "replace"))
hasher.update(b"\x00instructions\x00")
hasher.update(instructions.encode("utf-8", "replace"))
return hasher.hexdigest()
__all__ = ["prompt_prefix_signature"]
@@ -6,6 +6,7 @@ model selection based on task characteristics.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Any, Union
from .agent import Agent
@@ -1,95 +0,0 @@
"""Canonical terminal-outcome contract for agent runs.
A single, closed description of *how* a run ended, produced once by the core
run and consumed everywhere. Long-running hosts (e.g. gateways/bots) can make
delivery/retry/DLQ/status decisions from one field instead of inferring the
terminal state from exception identity.
Precedence is explicit and sticky: a ``hard_timeout`` is never silently
downgraded by later cleanup. See ``RunOutcome.from_exception``.
"""
from dataclasses import dataclass
from typing import Optional
try:
from typing import Literal
except ImportError: # pragma: no cover - py<3.8 fallback
Literal = None # type: ignore
if Literal is not None:
TerminalReason = Literal[
"completed", "hard_timeout", "cancelled", "aborted", "failed"
]
else: # pragma: no cover
TerminalReason = str # type: ignore
# Precedence: higher wins and is sticky (a hard timeout is not downgraded).
_REASON_PRECEDENCE = {
"completed": 0,
"failed": 1,
"aborted": 2,
"cancelled": 3,
"hard_timeout": 4,
}
@dataclass(frozen=True)
class RunOutcome:
"""Closed, canonical description of how an agent run ended.
Attributes:
reason: One of ``completed | hard_timeout | cancelled | aborted | failed``.
output: Partial or final text, if any.
error: Redacted message when ``reason == "failed"``.
"""
reason: "TerminalReason"
output: Optional[str] = None
error: Optional[str] = None
@property
def succeeded(self) -> bool:
"""True only when the run completed normally."""
return self.reason == "completed"
@classmethod
def completed(cls, output: Optional[str] = None) -> "RunOutcome":
return cls(reason="completed", output=output)
@classmethod
def from_exception(
cls, exc: BaseException, output: Optional[str] = None
) -> "RunOutcome":
"""Normalise a raised exception into a canonical terminal outcome.
Maps well-known exception types to their terminal reason. Anything
unrecognised is treated as ``failed`` with a redacted message.
Note on ``hard_timeout``: the run-level budget is the authoritative
source of ``hard_timeout`` and is applied explicitly by the caller
(see ``_astart_with_outcome``). A bare ``asyncio.TimeoutError`` reaching
this normaliser is a *nested* operation timeout (e.g. a handoff/tool that
exhausted its own budget while the run budget remained) and is therefore
classified as ``failed`` — not silently promoted to a run-budget
``hard_timeout``, which would drive the wrong host retry/DLQ decision.
Real cooperative cancellation is intercepted and re-raised by the run
wrapper (see ``_astart_with_outcome``) *before* it reaches this
normaliser, so host shutdown is honoured. A supersede/interrupt named
error that does surface here is a domain "cancelled" outcome.
"""
import asyncio
if _name_matches(exc, ("hardtimeout", "runtimeout", "budgettimeout")):
return cls(reason="hard_timeout", output=output)
if isinstance(exc, asyncio.CancelledError) or _name_matches(
exc, ("supersed", "interrupt", "cancelled")
):
return cls(reason="cancelled", output=output)
if _name_matches(exc, ("abort", "drain", "shutdown")):
return cls(reason="aborted", output=output)
return cls(reason="failed", output=output, error=str(exc))
def _name_matches(exc: BaseException, needles: tuple) -> bool:
name = type(exc).__name__.lower()
return any(n in name for n in needles)
@@ -5,6 +5,7 @@ Provides automatic summarization when context window fills up.
Reuses existing telemetry/token_collector for token tracking.
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
@@ -471,46 +471,6 @@ class ToolExecutionMixin:
self._middleware_manager = manager
return manager if manager.has_tool_hooks else None
def _get_turn_tools_lock(self):
"""Return the DualLock guarding the per-turn tool buffer.
Falls back to a lazily-created lock so subclasses or objects that
predate the lock attribute stay safe. The lock protects
``_turn_tools_used`` from corruption when concurrent chat()/achat()
turns run on the same Agent instance (issue #3307).
"""
lock = getattr(self, "_turn_tools_lock", None)
if lock is None:
from .async_safety import DualLock
lock = DualLock()
self._turn_tools_lock = lock
return lock
def _reset_turn_tools(self):
"""Reset the per-turn tool buffer under lock (start of a chat turn)."""
if getattr(self, "_in_skill_review", False):
return
with self._get_turn_tools_lock().sync():
self._turn_tools_used = []
def _record_turn_tool(self, function_name):
"""Append a tool name to the per-turn buffer under lock."""
if getattr(self, "_in_skill_review", False):
return
with self._get_turn_tools_lock().sync():
turn_tools = getattr(self, "_turn_tools_used", None)
if turn_tools is None:
turn_tools = []
self._turn_tools_used = turn_tools
turn_tools.append(function_name)
def _drain_turn_tools(self):
"""Return a copy of the per-turn buffer and clear it, under lock."""
with self._get_turn_tools_lock().sync():
tools_used = list(getattr(self, "_turn_tools_used", []) or [])
self._turn_tools_used = []
return tools_used
def _execute_tool_with_context(self, function_name, arguments, state, tool_call_id=None):
"""Execute tool within injection context, with optional output truncation.
@@ -527,9 +487,13 @@ class ToolExecutionMixin:
# Record the tool name for this turn so the self-improve review policy
# can see what ran (issue #3037). Skipped during a guarded review turn
# to avoid tracking the review's own tool calls or recursing. Locked so
# concurrent turns on the same Agent don't corrupt the buffer (#3307).
self._record_turn_tool(function_name)
# to avoid tracking the review's own tool calls or recursing.
if not getattr(self, "_in_skill_review", False):
turn_tools = getattr(self, "_turn_tools_used", None)
if turn_tools is None:
self._turn_tools_used = []
turn_tools = self._turn_tools_used
turn_tools.append(function_name)
# Emit tool call start event (zero overhead when not set)
_trace_emitter = get_context_emitter()
@@ -1168,9 +1132,8 @@ class ToolExecutionMixin:
# tools_used, so without this the review policy always sees an empty
# list and never runs. Consume the buffer so the next turn starts clean.
if tools_used is None:
tools_used = self._drain_turn_tools()
else:
self._drain_turn_tools()
tools_used = list(getattr(self, "_turn_tools_used", []) or [])
self._turn_tools_used = []
# Trigger AFTER_AGENT hook (only build the input if a hook is actually registered)
from ..hooks import HookEvent
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_AGENT):
@@ -1226,9 +1189,8 @@ class ToolExecutionMixin:
# Default tools_used from the per-turn buffer when the caller did not
# pass it explicitly (issue #3037); mirrors the sync path.
if tools_used is None:
tools_used = self._drain_turn_tools()
else:
self._drain_turn_tools()
tools_used = list(getattr(self, "_turn_tools_used", []) or [])
self._turn_tools_used = []
# Trigger AFTER_AGENT hook (only build the input if a hook is actually registered)
from ..hooks import HookEvent
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_AGENT):
@@ -1433,9 +1395,7 @@ class ToolExecutionMixin:
# only gate the built-in DEFAULT_DANGEROUS_TOOLS and silently skip
# registry-required tools.
approval_registry = get_approval_registry()
registry_required = approval_registry.is_required(
tool_name, getattr(self, 'name', None)
)
registry_required = approval_registry.is_required(tool_name)
# Check if tool needs approval based on multiple criteria
needs_approval = (
approve_all
@@ -1449,7 +1409,7 @@ class ToolExecutionMixin:
tool_name=tool_name,
arguments=tool_args,
risk_level=(
approval_registry.get_risk_level(tool_name, getattr(self, 'name', None))
approval_registry.get_risk_level(tool_name)
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
),
agent_name=getattr(self, 'name', None),
@@ -1514,19 +1474,21 @@ class ToolExecutionMixin:
return ApprovalDecision(approved=True, reason="Not a dangerous tool")
else:
# No approval backend configured. An explicit PermissionManager
# ``ask`` rule must still gate the call. Rather than mutating shared
# registry state (which leaked onto other agents when this agent had
# no stable name), pass the intent per-call via ``force`` so the
# registry prompts for *this* call only without side effects.
# ``ask`` rule must still gate the call, so mark it required in the
# approval registry (idempotent) before delegating so the registry
# prompts instead of silently allowing.
if manager_forces_approval:
try:
get_approval_registry().add_requirement(tool_name)
except Exception: # noqa: BLE001
pass
if is_async:
return get_approval_registry().approve_async(
getattr(self, 'name', None), tool_name, tool_args,
force=manager_forces_approval,
)
else:
return get_approval_registry().approve_sync(
getattr(self, 'name', None), tool_name, tool_args,
force=manager_forces_approval,
)
def _permission_manager_requires_approval(self, function_name) -> bool:
@@ -1640,9 +1602,7 @@ class ToolExecutionMixin:
if self._permission_manager_requires_approval(function_name):
return True
from ..approval import get_approval_registry
if get_approval_registry().is_required(
function_name, getattr(self, 'name', None)
):
if get_approval_registry().is_required(function_name):
return True
from ..tools import get_registry as get_tool_registry
if get_tool_registry().get_trust_level(function_name) == "external":
@@ -1879,35 +1839,6 @@ class ToolExecutionMixin:
raise last_exception
return {"error": "Maximum retry attempts exceeded"}
@staticmethod
def _remove_circuit_breaker(breaker_name):
"""Remove a circuit breaker entry from the process-global registry."""
try:
from ..tools.circuit_breaker import _get_global_registry
except Exception:
return
try:
_get_global_registry().remove(breaker_name)
except Exception:
pass
def _register_breaker_finalizer(self, breaker_name):
"""Register a weakref finalizer so this agent's breaker entry is removed
the moment the agent is garbage-collected, even if close() is never called.
"""
registered = self.__dict__.setdefault('_breaker_finalizer_names', set())
if breaker_name in registered:
return
registered.add(breaker_name)
try:
import weakref
finalizers = self.__dict__.setdefault('_breaker_finalizers', [])
finalizers.append(
weakref.finalize(self, self._remove_circuit_breaker, breaker_name)
)
except Exception:
pass
def _execute_tool_with_circuit_breaker_impl(self, function_name, arguments):
"""Execute tool with circuit breaker protection (internal implementation).
@@ -1939,13 +1870,7 @@ class ToolExecutionMixin:
graceful_degradation=True # Return error instead of raising exception
)
breaker = get_circuit_breaker(breaker_name, config)
# Ensure the registry entry is removed the moment this Agent is
# actually garbage-collected, regardless of whether close()/aclose()
# was ever called. This closes the CPython id-reuse window where a
# new Agent at the same address could inherit a stale OPEN breaker.
self._register_breaker_finalizer(breaker_name)
# Execute tool through circuit breaker with failure detection wrapper
def _tool_wrapper():
result = self._execute_tool_impl(function_name, arguments)
@@ -2142,11 +2067,8 @@ class ToolExecutionMixin:
func = tool
break
if func is None and getattr(self, '_allow_global_tools', False):
# Check the process-global tool registry for plugins. Gated behind
# ToolConfig(allow_global_tools=True) so an agent is scoped strictly
# to its declared tools=[...] by default (safe by default) and cannot
# execute an @tool-decorated callable it was never given.
if func is None:
# Check the global tool registry for plugins
try:
from ..tools.registry import get_registry
registry = get_registry()
@@ -2157,220 +2079,46 @@ class ToolExecutionMixin:
if func is None:
# Tool not found in declared tools or registry — do not fall back to
# globals() or __main__ as that allows undeclared callables to execute.
# Cheap, deterministic self-repair: the model often emits a name that
# only differs by case/separator (e.g. 'WebSearch' -> 'web_search').
# Build a normalised index of the agent's active tools and re-match.
def _norm(n):
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
normalised = {}
for name, tool in self._iter_active_named_tools():
normalised.setdefault(_norm(name), []).append((name, tool))
match = normalised.get(_norm(function_name))
if match and len(match) == 1:
matched_name, func = match[0]
logging.debug(
f"Self-repaired tool name {function_name!r} -> {matched_name!r}"
)
pass
if func:
bind_target = func
bind_arguments = arguments
try:
# BaseTool instances (plugin system) - call run() method
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
bind_target = func.run
casted_arguments = self._cast_arguments(func.run, arguments)
bind_arguments = casted_arguments
return func.run(**casted_arguments)
# Langchain: If it's a class with run but not _run, instantiate and call run
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
instance = func()
bind_target = instance.run
run_params = {k: v for k, v in arguments.items()
if k in inspect.signature(instance.run).parameters
and k != 'self'}
casted_params = self._cast_arguments(instance.run, run_params)
bind_arguments = casted_params
return instance.run(**casted_params)
# CrewAI: If it's a class with an _run method, instantiate and call _run
elif inspect.isclass(func) and hasattr(func, '_run'):
instance = func()
bind_target = instance._run
run_params = {k: v for k, v in arguments.items()
if k in inspect.signature(instance._run).parameters
and k != 'self'}
casted_params = self._cast_arguments(instance._run, run_params)
bind_arguments = casted_params
return instance._run(**casted_params)
# Otherwise treat as regular function
elif callable(func):
bind_target = func
casted_arguments = self._cast_arguments(func, arguments)
bind_arguments = casted_arguments
return func(**casted_arguments)
except Exception as e:
error_msg = str(e)
logging.error(f"Error executing tool {function_name}: {error_msg}")
# Only echo the parameter schema when the failure is a genuine
# argument-binding error (wrong/missing/extra kwargs). A
# TypeError/ValueError raised *inside* a successfully-bound tool
# (domain validation) must not be mislabelled as a parameter
# problem, or the model would alter valid call arguments instead
# of fixing the offending value.
if self._is_argument_binding_error(bind_target, bind_arguments):
schema = self._tool_parameter_hint(func)
if schema:
# Fold the parameter names into the error string itself so
# the hint survives conversion to ToolExecutionError (which
# keeps only the message) and actually reaches the model.
error_msg = (
f"{error_msg} Expected parameters for '{function_name}' — "
f"required: {schema['required']}, optional: {schema['optional']}."
)
return {"error": error_msg, "expected_parameters": schema}
return {"error": error_msg}
# Unresolved: return a corrective, model-readable message so the model can
# retry with a valid name instead of repeating the same mistake.
available = self._available_active_tool_names()
suggestion = None
try:
import difflib
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
suggestion = near[0] if near else None
except Exception:
pass
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
error_msg = (
f"Tool '{function_name}' not found.{hint} "
f"Available tools: {available}"
)
error_msg = f"Tool '{function_name}' is not callable"
logging.error(error_msg)
return {"error": error_msg, "available_tools": available}
def _get_tool_display_name(self, tool):
"""Best-effort display name for an agent tool of any supported kind."""
try:
from ..tools.base import BaseTool
if isinstance(tool, BaseTool):
return getattr(tool, 'name', None)
except ImportError:
pass
name = getattr(tool, 'name', None)
if isinstance(name, str) and name:
return name
if callable(tool) or inspect.isclass(tool):
return getattr(tool, '__name__', None)
return None
def _iter_active_named_tools(self):
"""Yield ``(name, tool)`` for every active tool.
MCP instances are expanded into their contained tools (each MCP tool is
an iterable callable with a ``__name__``/``name``) so they participate
in name repair and appear in the corrective inventory, rather than only
exposing the opaque container.
"""
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass
def _expand(tool):
if MCP is not None and isinstance(tool, MCP):
try:
for sub in tool:
sub_name = self._get_tool_display_name(sub)
if sub_name:
yield sub_name, sub
except Exception:
pass
return
name = self._get_tool_display_name(tool)
if name:
yield name, tool
tools = self.tools
if MCP is not None and isinstance(tools, MCP):
yield from _expand(tools)
return
for tool in tools if isinstance(tools, (list, tuple)) else []:
yield from _expand(tool)
def _available_active_tool_names(self):
"""Names of the agent's currently active tools, for corrective feedback."""
names = [name for name, _tool in self._iter_active_named_tools()]
return sorted(set(names))
def _resolve_callable_signature_target(self, func):
"""Return the callable whose signature describes ``func``'s arguments."""
target = func
try:
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
return func.run
except ImportError:
pass
if inspect.isclass(func):
run = getattr(func, 'run', None) or getattr(func, '_run', None)
if run is not None:
target = run
return target
def _is_argument_binding_error(self, func, arguments) -> bool:
"""True only when ``arguments`` cannot bind to ``func``'s signature.
Distinguishes a genuine call-boundary failure (wrong/missing/extra
kwargs) from a ``TypeError``/``ValueError`` raised *inside* a
successfully-bound tool during its own domain logic. Only the former
should receive a parameter-schema hint; the latter is a runtime error
the model must fix by changing the value, not the parameter names.
"""
target = self._resolve_callable_signature_target(func)
try:
sig = inspect.signature(target)
except (TypeError, ValueError):
return False
try:
sig.bind(**(arguments or {}))
except TypeError:
return True
return False
def _tool_parameter_hint(self, func):
"""Return {'required': [...], 'optional': [...]} for a callable tool."""
target = func
try:
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
target = func.run
elif inspect.isclass(func):
target = getattr(func, 'run', None) or getattr(func, '_run', None)
if target is None or not callable(target):
return None
sig = inspect.signature(target)
required, optional = [], []
for pname, param in sig.parameters.items():
if pname == 'self' or param.kind in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
continue
if param.default is inspect.Parameter.empty:
required.append(pname)
else:
optional.append(pname)
if not required and not optional:
return None
return {"required": required, "optional": optional}
except (ValueError, TypeError):
return None
return {"error": error_msg}
async def submit_for_approval(self, function_name: str, arguments: Dict[str, Any]) -> str:
"""Fire an approval request in the background without blocking.
@@ -330,26 +330,17 @@ async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, st
)
def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
def _build_execution_context(agents_instance, task_id):
"""
Build unified execution context for task execution (DRY helper).
Eliminates duplication between sync/async execution paths.
Args:
skip_memory_init: When True, skip the synchronous ``initialize_memory()``
call. The async path (``aexecute_task``) already attempts
``initialize_memory_async()`` beforehand, so this avoids blocking the
event loop on a synchronous ``Memory()`` construction (and a
duplicate backend attempt) if that async init failed.
"""
from .protocols import ExecutionContext
task = agents_instance.tasks[task_id]
# Initialize memory before task execution. The async path (aexecute_task)
# already attempts initialize_memory_async() and passes skip_memory_init=True
# so we never block the event loop on a synchronous Memory() construction.
if not task.memory and not skip_memory_init:
# Initialize memory before task execution
if not task.memory:
task.memory = task.initialize_memory()
executor_agent = task.agent
@@ -851,11 +842,6 @@ class AgentTeam(SpawnAnnounceProtocol):
self._event_bus: Optional[EventBus] = None
self._spawn_lock = threading.RLock() # Thread-safe spawn operations (reentrant)
self._team_id = str(uuid.uuid4()) # Unique team identifier
# Aggregate stream emitter (lazy). Fans in member agents' per-step
# StreamEventEmitter events, tagging each with the emitting agent's id,
# so a single consumer can attribute activity to a specific team member.
self.__stream_emitter = None
self.__stream_fanin_wired = False
# Check for manager_llm in environment variable if not provided
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
@@ -974,63 +960,6 @@ class AgentTeam(SpawnAnnounceProtocol):
except (ImportError, AttributeError):
self._telemetry = None
@property
def stream_emitter(self):
"""Aggregate ``StreamEventEmitter`` fanning in member agents' events.
Lazily created on first access. On creation it registers a forwarding
callback on each member agent's own ``stream_emitter`` so per-step
events (tool calls, text/reasoning deltas, retries, errors) surface on a
single team-level emitter, each tagged with the emitting agent's
``agent_id``. This gives a single attach point (e.g. the CLI
``--output stream-json`` bridge) parity with single-agent runs.
Zero-overhead when unused: nothing is wired unless this property is
accessed, and forwarding only tags ``agent_id`` when the source event
did not already carry one.
"""
if self.__stream_emitter is None:
try:
from ..streaming.events import StreamEventEmitter
except ImportError:
return None
self.__stream_emitter = StreamEventEmitter()
if not self.__stream_fanin_wired:
self._wire_stream_fanin()
return self.__stream_emitter
def _wire_stream_fanin(self):
"""Forward each member agent's stream events onto the team emitter.
Best-effort and idempotent: an agent whose emitter is unavailable is
skipped, and a forwarding callback is attached at most once per team.
"""
team_emitter = self.__stream_emitter
if team_emitter is None:
return
self.__stream_fanin_wired = True
for agent in self.agents:
member_emitter = getattr(agent, "stream_emitter", None)
if member_emitter is None or not hasattr(member_emitter, "add_callback"):
continue
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "display_name", None)
member_emitter.add_callback(self._make_fanin_callback(team_emitter, agent_id))
@staticmethod
def _make_fanin_callback(team_emitter, agent_id):
"""Build a callback that re-emits an event on the team emitter."""
def _forward(event):
try:
if agent_id is not None and getattr(event, "agent_id", None) is None:
try:
event.agent_id = agent_id
except (AttributeError, TypeError):
pass
team_emitter.emit(event)
except Exception:
logger.debug("AgentTeam stream fan-in forward failed", exc_info=True)
return _forward
def add_task(self, task):
with self._task_id_lock:
task_id = self.task_id_counter
@@ -1125,17 +1054,8 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status == "not started":
task.status = "in progress"
# Initialize memory asynchronously to avoid blocking the event loop on
# synchronous Memory() construction. The shared helper's own
# `if not task.memory:` guard makes this a safe no-op for the sync path.
if not task.memory:
await task.initialize_memory_async()
# Build execution context using DRY helper. skip_memory_init=True prevents
# the helper from falling back to the *synchronous* initialize_memory()
# (which would block the event loop and duplicate a failed backend attempt)
# when the async init above did not populate task.memory.
context = _build_execution_context(self, task_id, skip_memory_init=True)
# Build execution context using DRY helper
context = _build_execution_context(self, task_id)
# Execute with agent using DRY helper
agent_output = await _execute_with_agent_async(
@@ -1528,13 +1448,7 @@ class AgentTeam(SpawnAnnounceProtocol):
except Exception as e:
logger.error(f"Error executing memory callback for task {task_id}: {e}")
logger.exception(e)
# Respect task failure policies - re-raise if configured
# (mirrors arun_task so sync/async surfaces behave identically)
if hasattr(task, 'fail_on_callback_error') and task.fail_on_callback_error:
raise
if hasattr(task, 'fail_on_memory_error') and task.fail_on_memory_error:
raise
self.save_output_to_file(task, task_output)
# Call on_task_complete callback (shared with arun_task)
@@ -17,6 +17,7 @@ Usage:
result = auto_rag.chat("Hello!") # Skips retrieval
"""
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -5,6 +5,7 @@ Provides delegation primitives for spawning subagents with scoped permissions
and context. Inspired by Gemini CLI's delegate-to-agent pattern.
"""
import logging
from praisonaiagents._logging import get_logger
import asyncio
import warnings
@@ -11,6 +11,7 @@ Provides lightweight backends that ship with the core SDK:
from __future__ import annotations
import asyncio
import logging
from praisonaiagents._logging import get_logger
from typing import Any
@@ -20,6 +20,7 @@ import asyncio
import contextvars
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
import os
from typing import Dict, List, Optional, Set
@@ -90,19 +91,10 @@ class ApprovalRegistry:
self._global_backend = None # type: ignore[assignment]
self._agent_backends: Dict[str, object] = {}
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS).
# These hold process-wide defaults (e.g. DEFAULT_DANGEROUS_TOOLS and any
# intentional global registration).
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS)
self._required_tools: Set[str] = set()
self._risk_levels: Dict[str, str] = {}
# Per-agent tool requirements. A PermissionManager ``ask`` rule belongs
# to a single agent, so it must not leak an approval gate onto unrelated
# agents sharing the same process. Mirrors the agent-keyed pattern used
# by ``_agent_backends`` / ``_agent_tool_auto_approve``.
self._agent_required_tools: Dict[str, Set[str]] = {}
self._agent_risk_levels: Dict[tuple[str, str], str] = {}
# Per-agent, per-tool auto-approval (G-A fix)
self._agent_tool_auto_approve: Dict[tuple[str, str], bool] = {}
@@ -161,51 +153,18 @@ class ApprovalRegistry:
# ── Tool requirement management ──────────────────────────────────────
def add_requirement(
self,
tool_name: str,
risk_level: str = "high",
agent_name: Optional[str] = None,
) -> None:
"""Mark *tool_name* as requiring approval.
def add_requirement(self, tool_name: str, risk_level: str = "high") -> None:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk_level
When *agent_name* is given the requirement is scoped to that agent only
(used for per-agent ``PermissionManager`` ``ask`` rules), so it never
forces approval onto other agents in the same process. Omitting
*agent_name* keeps the historical process-wide behaviour used for
genuinely dangerous tools registered at startup.
"""
if agent_name:
self._agent_required_tools.setdefault(agent_name, set()).add(tool_name)
self._agent_risk_levels[(agent_name, tool_name)] = risk_level
else:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk_level
def remove_requirement(self, tool_name: str) -> None:
self._required_tools.discard(tool_name)
self._risk_levels.pop(tool_name, None)
def remove_requirement(
self, tool_name: str, agent_name: Optional[str] = None
) -> None:
if agent_name:
tools = self._agent_required_tools.get(agent_name)
if tools is not None:
tools.discard(tool_name)
self._agent_risk_levels.pop((agent_name, tool_name), None)
else:
self._required_tools.discard(tool_name)
self._risk_levels.pop(tool_name, None)
def is_required(self, tool_name: str, agent_name: Optional[str] = None) -> bool:
if agent_name and tool_name in self._agent_required_tools.get(agent_name, ()):
return True
def is_required(self, tool_name: str) -> bool:
return tool_name in self._required_tools
def get_risk_level(
self, tool_name: str, agent_name: Optional[str] = None
) -> Optional[str]:
if agent_name:
level = self._agent_risk_levels.get((agent_name, tool_name))
if level is not None:
return level
def get_risk_level(self, tool_name: str) -> Optional[str]:
return self._risk_levels.get(tool_name)
# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────
@@ -236,11 +195,9 @@ class ApprovalRegistry:
self._approved_context.set(approved)
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
# Honour an explicit mark_approved() from the agent approval path even
# for critical tools (e.g. execute_command after AutoApproveBackend).
if self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set()):
return True
return False
if self.get_risk_level(tool_name) == "critical":
return False
return self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set())
def _is_session_scoped(
self, agent_name: Optional[str], tool_name: str, arguments: Optional[Dict]
@@ -387,17 +344,10 @@ class ApprovalRegistry:
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
force: bool = False,
) -> ApprovalDecision:
"""Synchronous approval — used by ``Agent._execute_tool_impl``.
``force`` gates this single call even when the tool is not otherwise
registered as requiring approval (e.g. a per-agent ``PermissionManager``
``ask`` rule). It applies only to this call and never mutates shared
registry state, so it cannot leak an approval gate onto other agents.
"""
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
"""Synchronous approval — used by ``Agent._execute_tool_impl``."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
@@ -429,7 +379,7 @@ class ApprovalRegistry:
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
@@ -454,15 +404,10 @@ class ApprovalRegistry:
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
force: bool = False,
) -> ApprovalDecision:
"""Asynchronous approval — used by async tool execution path.
See :meth:`approve_sync` for the ``force`` semantics (per-call gate,
no shared-state mutation).
"""
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
"""Asynchronous approval — used by async tool execution path."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name, arguments):
@@ -490,7 +435,7 @@ class ApprovalRegistry:
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
@@ -6,12 +6,20 @@ import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional
from .protocols import AuthError, SubscriptionCredentials
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
from .registry import register_subscription_provider
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
_TOKEN_ENDPOINTS = (
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
)
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
@@ -89,6 +97,43 @@ def _read_file_credentials() -> Optional[Dict[str, Any]]:
}
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
if not expires_at_ms:
return False
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
def _refresh(refresh_token: str) -> Dict[str, Any]:
"""Pure refresh — does not touch local files."""
if not refresh_token:
raise AuthError("no refresh_token; please re-run 'claude /login'")
body = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": _OAUTH_CLIENT_ID,
}).encode()
last = None
for endpoint in _TOKEN_ENDPOINTS:
req = urllib.request.Request(
endpoint, data=body, method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode())
return {
"accessToken": payload["access_token"],
"refreshToken": payload.get("refresh_token", refresh_token),
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
}
except Exception as exc:
last = exc
raise AuthError(f"Anthropic refresh failed: {last}")
class ClaudeCodeAuth:
"""Claude Code OAuth subscription auth."""
@@ -114,9 +159,8 @@ class ClaudeCodeAuth:
"No Claude Code credentials found. Install Claude Code and run "
"'claude /login', or set ANTHROPIC_TOKEN."
)
# Read-only: never refresh here. Anthropic rotates refresh tokens and
# PraisonAI does not write back to Keychain, which would invalidate
# Claude CLI and other tools sharing the same session.
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
creds.update(_refresh(creds["refreshToken"]))
return SubscriptionCredentials(
api_key=creds["accessToken"],
base_url="https://api.anthropic.com",
@@ -127,11 +171,17 @@ class ClaudeCodeAuth:
)
def refresh(self) -> SubscriptionCredentials:
raise AuthError(
"PraisonAI does not refresh shared Claude Code OAuth sessions "
"(refresh-token rotation would invalidate Claude CLI and other "
"tools using the same Keychain entry). Run 'claude /login' or "
"let Claude CLI refresh credentials, then retry."
creds = _read_keychain_credentials() or _read_file_credentials()
if not creds or not creds.get("refreshToken"):
raise AuthError("cannot refresh: no refresh token available")
new = _refresh(creds["refreshToken"])
return SubscriptionCredentials(
api_key=new["accessToken"],
base_url="https://api.anthropic.com",
headers=self.headers_for("https://api.anthropic.com", ""),
auth_scheme="bearer",
expires_at_ms=new["expiresAt"],
source="claude-code-refreshed",
)
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
@@ -140,7 +190,8 @@ class ClaudeCodeAuth:
return {
"anthropic-beta": ",".join([
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"context-1m-2025-08-07",
"claude-code-20250219",
]),
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
@@ -5,6 +5,7 @@ Manages background task execution.
"""
import asyncio
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any, Callable, Union
@@ -6,6 +6,7 @@ Represents a task running in the background.
import uuid
import asyncio
import logging
from praisonaiagents._logging import get_logger
from enum import Enum
from dataclasses import dataclass, field
@@ -31,11 +31,6 @@ from .protocols import (
ChannelField,
ChannelDescriptor,
WebhookVerifierProtocol,
CallbackPayloadStoreProtocol,
InMemoryCallbackPayloadStore,
GatewayRuntimeSeams,
SupportsGatewayRuntime,
GatewayAdapterContractError,
)
from .base import (
BasePlatformAdapter,
@@ -123,11 +118,6 @@ __all__ = [
"ChannelField",
"ChannelDescriptor",
"WebhookVerifierProtocol",
"CallbackPayloadStoreProtocol",
"InMemoryCallbackPayloadStore",
"GatewayRuntimeSeams",
"SupportsGatewayRuntime",
"GatewayAdapterContractError",
"BasePlatformAdapter",
"SendResult",
"InteractiveContext",
@@ -51,7 +51,7 @@ class BotConfig:
thread_threshold: int = 500 # Auto-thread responses longer than this (0 = disabled)
# Group message policy
group_policy: str = "mention_only" # respond_all, mention_only, command_only, observe
group_policy: str = "mention_only" # respond_all, mention_only, command_only
# Default safe tools (auto-injected for bots with no tools configured)
# With workspace scoping, file operations are now safe by construction
@@ -17,39 +17,10 @@ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKIN
if TYPE_CHECKING:
from .presentation import PresentationAction
from .protocols import BotAdapter, BotMessage, CallbackPayloadStoreProtocol
from .protocols import BotAdapter, BotMessage
logger = logging.getLogger(__name__)
# Marker prefixing a stored-reference callback payload. Mirrors
# ``presentation.CALLBACK_REF_MARKER``; kept local so the inbound handler does
# not import the (heavier) render module. When an interactive value is too long
# for the channel callback byte-cap and a callback-payload store is available,
# the encoder emits ``<namespace>:@<ref>``; the registry resolves the reference
# back to the exact value the agent authored before routing it.
_CALLBACK_REF_MARKER = "@"
def _extract_ref(value: Optional[str]) -> Optional[str]:
"""Return the stored-payload reference in ``value``, or ``None``.
The decoded callback ``value`` is either the whole payload (``reply``) or a
``<action_id>:<value>`` tail (``select``). A stored reference always appears
as a trailing ``@<ref>`` segment — i.e. the marker sits at the start of the
value or immediately after a ``:`` separator. Requiring that anchoring means
an ordinary value that merely contains an ``@`` (e.g. an email address) is
never mistaken for a reference.
"""
if not isinstance(value, str) or not value:
return None
if value.startswith(_CALLBACK_REF_MARKER):
return value[len(_CALLBACK_REF_MARKER):] or None
sep = f":{_CALLBACK_REF_MARKER}"
idx = value.rfind(sep)
if idx != -1:
return value[idx + len(sep):] or None
return None
@dataclass
class InteractiveContext:
@@ -205,25 +176,11 @@ class InteractiveRegistry:
callback namespaces. Each namespace can have one handler.
"""
def __init__(
self,
store: Optional["CallbackPayloadStoreProtocol"] = None,
):
"""Initialize the registry.
Args:
store: Optional :class:`CallbackPayloadStoreProtocol` used to resolve
stored-reference callbacks (``<namespace>:@<ref>``) emitted when
an interactive value was too long to travel inline on a
tight-callback-cap channel. When supplied, a reference is
resolved back to the exact value before the handler runs, so
long ``reply``/``select`` values round-trip losslessly. When
omitted, behaviour is unchanged.
"""
def __init__(self):
"""Initialize the registry."""
self._handlers: Dict[str, InteractiveHandler] = {}
self._authorizers: Dict[str, "InteractiveAuthorizer"] = {}
self._fallback_handler: Optional[InteractiveHandler] = None
self._store = store
def register(
self,
@@ -282,39 +239,7 @@ class InteractiveRegistry:
True if handled, False otherwise
"""
namespace, payload = decode_callback(context.callback_data)
# Resolve a stored-reference payload (``<namespace>:@<ref>``) back to the
# canonical value the agent authored. Long ``reply``/``select`` values
# that overflow the channel callback byte-cap are persisted under a short
# reference on render; here we restore the exact value before routing so
# the handler receives what the user actually chose. An unknown/expired
# reference is dropped (fails closed) rather than routing the opaque
# reference token.
value = payload.get("value") if isinstance(payload, dict) else None
ref = _extract_ref(value)
if ref is not None:
resolved = None
if self._store is not None:
try:
resolved = self._store.get(ref)
except Exception as e: # pragma: no cover - defensive
logger.error(f"Callback payload store lookup failed: {e}")
resolved = None
if resolved is None:
logger.warning(
"Interactive callback referenced a stored value that is "
"unknown or expired; dropping rather than routing an "
"unresolvable reference."
)
return False
# Replace only the trailing ``@<ref>`` segment with the restored
# value, preserving any leading ``<action_id>:`` prefix (the
# ``select`` payload is ``<action_id>:<value>``) so handlers that
# parse the action scope still see it.
marked = f"{_CALLBACK_REF_MARKER}{ref}"
payload = dict(payload)
payload["value"] = value[: len(value) - len(marked)] + resolved
# Try to find a handler for this namespace
handler = self._handlers.get(namespace)
@@ -400,27 +325,16 @@ class InteractiveRegistry:
_global_registry = InteractiveRegistry()
def create_registry(
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> InteractiveRegistry:
def create_registry() -> InteractiveRegistry:
"""Create a new interactive registry instance.
Each adapter should create its own registry to avoid conflicts
when multiple adapters are used in the same process.
Args:
store: Optional :class:`CallbackPayloadStoreProtocol` used to resolve
stored-reference callbacks (``<namespace>:@<ref>``). Pass the same
store instance that the render side hands to
``adapt_presentation(..., callback_store=...)`` so long
``reply``/``select`` values round-trip losslessly on
tight-callback-cap channels (e.g. Telegram). When omitted,
behaviour is unchanged.
Returns:
A new InteractiveRegistry instance
"""
return InteractiveRegistry(store=store)
return InteractiveRegistry()
def get_registry() -> InteractiveRegistry:
@@ -12,41 +12,14 @@ rendering belongs in ``praisonai-bot`` (``praisonai_bot.bots``).
from __future__ import annotations
import hashlib
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
if TYPE_CHECKING:
from .protocols import CallbackPayloadStoreProtocol
from typing import Any, Dict, List, Literal, Optional, Union
# Most channels (e.g. Telegram) hard-cap inline callback payloads at 64 bytes.
# Keep degraded select callbacks within this bound while preserving uniqueness.
_MAX_CALLBACK_LEN = 64
# Marker prefixing a stored-reference payload. When an interactive value does
# not fit the channel callback byte-cap and a ``CallbackPayloadStoreProtocol``
# is available, the canonical value is persisted under a short reference and the
# callback carries ``@<ref>`` instead of the (unrecoverable) hash. The inbound
# registry resolves the reference back to the exact value on click.
CALLBACK_REF_MARKER = "@"
# Default lifetime (seconds) for a persisted callback reference. An interactive
# menu is a short-lived affordance; a bounded TTL keeps the store from growing
# without cleanup while comfortably covering a user pondering their choice.
CALLBACK_REF_TTL = 3600.0
def _callback_ref(namespace: str, value: str) -> str:
"""Build a short, collision-resistant reference for a callback ``value``.
The reference is derived from both the namespace/action scope and the value
so distinct choices stay distinct, and is short enough to always fit within
``_MAX_CALLBACK_LEN`` alongside its prefix.
"""
digest = hashlib.sha256(f"{namespace}\x00{value}".encode("utf-8")).hexdigest()
return digest[:16]
class ActionType(str, Enum):
"""Types of actions that can be triggered by interactive elements."""
@@ -597,11 +570,7 @@ class PresentationLimits:
)
def _adapt_button(
button: PresentationButton,
limits: PresentationLimits,
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> PresentationButton:
def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> PresentationButton:
"""Return a copy of a button adapted to the given limits.
Truncates the label and, when the channel does not support web apps,
@@ -622,7 +591,7 @@ def _adapt_button(
if action_type == ActionType.REPLY.value and action.value is not None:
action = PresentationAction(
type=ActionType.CALLBACK,
value=_encode_reply_callback(action.value, store),
value=_encode_reply_callback(action.value),
)
elif (
not limits.supports_web_apps
@@ -655,10 +624,7 @@ REPLY_CALLBACK_PREFIX = "reply:"
REPLY_HASH_MARKER = "#"
def _encode_reply_callback(
value: str,
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> str:
def _encode_reply_callback(value: str) -> str:
"""Build a channel-safe callback payload for a ``reply`` action.
Produces ``reply:<value>`` so the inbound interactive registry can route
@@ -666,59 +632,33 @@ def _encode_reply_callback(
in UTF-8 bytes because channel callback caps (e.g. Telegram's 64-byte cap)
are byte limits, not character limits.
When the raw form exceeds ``_MAX_CALLBACK_LEN`` and a *store* is available,
the canonical value is persisted under a short reference and the callback
carries ``reply:@<ref>``; the inbound handler resolves the reference back to
the exact value, so long values round-trip losslessly.
Without a store the value is replaced with a short, collision-resistant hash
marked with ``#`` (``reply:#<digest>``). Truncating to a prefix was unsafe:
two long choices sharing a prefix would collapse to the same payload. The
marker lets the reply handler recognise that the original value could not be
carried and avoid routing a lossy value into the turn.
When the raw form exceeds ``_MAX_CALLBACK_LEN`` the value is replaced with a
short, collision-resistant hash marked with ``#`` (``reply:#<digest>``).
Truncating to a prefix was unsafe: two long choices sharing a prefix would
collapse to the same payload, and the agent would receive a value it never
authored. The hash keeps distinct choices distinct; the marker lets the
reply handler recognise that the original value could not be carried inline
and avoid routing a lossy value into the turn.
"""
raw = f"{REPLY_CALLBACK_PREFIX}{value}"
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
if store is not None:
ref = _callback_ref(REPLY_CALLBACK_PREFIX.rstrip(":"), value)
store.put(ref, value, expires_at=time.time() + CALLBACK_REF_TTL)
return f"{REPLY_CALLBACK_PREFIX}{CALLBACK_REF_MARKER}{ref}"
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
return f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}{digest}"
def _encode_select_callback(
action_id: str,
value: str,
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> str:
def _encode_select_callback(action_id: str, value: str) -> str:
"""Build a channel-safe callback payload for a degraded select option.
The raw ``select:<action_id>:<value>`` form can exceed channel callback
limits (e.g. Telegram's 64-byte cap). When it fits it is returned unchanged.
When it overflows and a *store* is available, the canonical value is
persisted under a short reference and the callback carries
``select:<action_id>:@<ref>``; the registry resolves the reference back to
the exact value on click, so long option values round-trip losslessly.
Without a store the value is replaced with a short, collision-resistant hash
so distinct options stay distinct after truncation (but the value cannot be
recovered — see the issue this addresses).
limits (e.g. Telegram's 64-byte cap) and collide when distinct options
share a long prefix. When the raw payload fits within ``_MAX_CALLBACK_LEN``
it is returned unchanged; otherwise the value is replaced with a short,
collision-resistant hash so distinct options stay distinct after truncation.
"""
raw = f"select:{action_id}:{value}"
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
if store is not None:
ref = _callback_ref(f"select:{action_id}", value)
store.put(ref, value, expires_at=time.time() + CALLBACK_REF_TTL)
prefix = f"select:{action_id}:{CALLBACK_REF_MARKER}"
if len(prefix.encode("utf-8")) + len(ref) > _MAX_CALLBACK_LEN:
# action_id itself is long; hash it so the ref still fits the bound.
aid_digest = hashlib.sha1((action_id or "").encode("utf-8")).hexdigest()[:8]
prefix = f"select:{aid_digest}:{CALLBACK_REF_MARKER}"
return f"{prefix}{ref}"
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
prefix = f"select:{action_id}:"
# Reserve room for the digest; trim the action_id prefix if needed.
@@ -730,10 +670,7 @@ def _encode_select_callback(
return f"{prefix[:_MAX_CALLBACK_LEN - len(digest)]}{digest}"
def _select_to_buttons(
block: PresentationBlock,
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> PresentationBlock:
def _select_to_buttons(block: PresentationBlock) -> PresentationBlock:
"""Convert a SELECT block into an equivalent BUTTONS block.
Used when a channel does not support native select menus. Each option
@@ -751,7 +688,7 @@ def _select_to_buttons(
label=label,
action=PresentationAction(
type=ActionType.CALLBACK,
value=_encode_select_callback(action_id, option.value, store),
value=_encode_select_callback(action_id, option.value),
),
)
)
@@ -761,8 +698,6 @@ def _select_to_buttons(
def adapt_presentation(
presentation: MessagePresentation,
limits: PresentationLimits,
*,
callback_store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> MessagePresentation:
"""Return a copy of ``presentation`` guaranteed to satisfy ``limits``.
@@ -785,12 +720,6 @@ def adapt_presentation(
Args:
presentation: The portable presentation to adapt.
limits: The target channel's capability limits.
callback_store: Optional :class:`CallbackPayloadStoreProtocol`. When a
``reply``/``select`` value overflows the channel callback byte-cap
and a store is supplied, the canonical value is persisted under a
short reference and the callback carries ``@<ref>`` so the inbound
registry can resolve the exact value on click. When omitted the
existing (lossy) hash behaviour is preserved for compatibility.
Returns:
A new ``MessagePresentation`` that is safe to render natively.
@@ -802,7 +731,7 @@ def adapt_presentation(
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Degrade select -> buttons, then adapt the resulting buttons block
block = _select_to_buttons(block, callback_store)
block = _select_to_buttons(block)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.BUTTONS.value and block.buttons:
@@ -825,7 +754,7 @@ def adapt_presentation(
kept.sort(key=lambda iv: iv[0])
buttons = [b for _, b in kept]
adapted_buttons = [_adapt_button(b, limits, callback_store) for b in buttons]
adapted_buttons = [_adapt_button(b, limits) for b in buttons]
adapted_blocks.append(
PresentationBlock(type=BlockType.BUTTONS, buttons=adapted_buttons)
)
@@ -302,79 +302,6 @@ class WebhookVerifierProtocol(Protocol):
...
class GatewayAdapterContractError(TypeError):
"""Raised when a channel adapter cannot receive the gateway runtime seams.
The gateway wires four reliability seams into every channel adapter —
the cross-platform identity resolver, the delivery router, the admission
gate, and the shared per-turn lock map. When the gateway has these seams
to inject but the adapter neither implements
:class:`SupportsGatewayRuntime` nor exposes a compatible session, the wiring
would silently no-op and the adapter would run without admission control,
durable delivery routing, or cross-platform turn locking. Failing loudly
with this error surfaces the missing contract instead.
"""
@dataclass
class GatewayRuntimeSeams:
"""The gateway reliability seams handed to a channel adapter at build time.
A single, typed carrier for the four runtime seams the gateway injects so
an adapter wires them up in one place instead of via four duck-typed
private-attribute splices:
Attributes:
identity_resolver: Cross-platform identity resolver; unifies the same
human across platforms onto one session key.
delivery_router: Backing router for the built-in ``send_message`` tool,
so an agent turn can proactively reach the user mid-task.
admission_gate: Gateway-wide admission control (concurrency ceiling /
fair queue / backpressure) applied to inbound runs.
turn_lock_map: Shared per-turn lock map for cross-platform turn
serialisation on the resolved session id.
A seam left ``None`` means the gateway has nothing to inject for it and the
adapter should keep whatever it already has.
"""
identity_resolver: Optional[Any] = None
delivery_router: Optional[Any] = None
admission_gate: Optional[Any] = None
turn_lock_map: Optional[Any] = None
@runtime_checkable
class SupportsGatewayRuntime(Protocol):
"""Contract for channel adapters that accept the gateway runtime seams.
Instead of the gateway reaching into an adapter's private session and
splicing ``_identity_resolver`` / ``_delivery_router`` / ``_admission_gate``
/ ``_locks`` via ``getattr``/``hasattr`` (which silently no-ops on any
adapter whose session does not match the built-in shape), an adapter — in
tree or ``pip``-installed — implements this one method. The gateway calls it
once with a :class:`GatewayRuntimeSeams`, so the same reliability guarantees
(admission/backpressure, delivery routing, cross-platform turn locking)
hold for *every* adapter.
Adapters whose ``__init__`` creates a ``BotSessionManager`` get this for
free — the session manager implements ``attach_gateway_runtime`` and the
adapter can simply delegate to ``self._session.attach_gateway_runtime(...)``.
"""
def attach_gateway_runtime(self, runtime: "GatewayRuntimeSeams") -> None:
"""Wire the gateway runtime seams into this adapter.
Only seams present (non-``None``) on ``runtime`` should be applied;
seams left ``None`` mean the gateway has nothing to inject and the
adapter must keep its existing value.
Args:
runtime: The gateway reliability seams to attach.
"""
...
class MessageType(str, Enum):
"""Types of bot messages."""
@@ -1347,77 +1274,3 @@ class BotOSProtocol(Protocol):
The Bot instance, or None if not found.
"""
...
@runtime_checkable
class CallbackPayloadStoreProtocol(Protocol):
"""Protocol for durable, reference-addressable interactive callback values.
Some channels hard-cap inline callback payloads (e.g. Telegram's 64-byte
inline-callback limit). When an interactive ``reply``/``select`` value is
too long to travel inline, the framework persists the canonical value under
a short, collision-resistant reference and emits that reference in the
callback instead. On click the registry resolves the reference back to the
exact value — so long option values (URLs, file paths, free-text choices)
round-trip losslessly on every channel.
This mirrors :class:`ApprovalStoreProtocol`: the contract lives in core so
any backend and any channel can interoperate; core ships a bounded
in-memory default (:class:`InMemoryCallbackPayloadStore`) and heavier
durable backends (e.g. SQLite) live in the ``praisonai-bot`` runtime.
Behaviour is unchanged when no store is configured — values that fit inline
are always sent inline, so this is additive and backward compatible.
"""
def put(self, ref: str, value: str, *, expires_at: float) -> None:
"""Persist ``value`` under ``ref`` until ``expires_at`` (epoch seconds).
Called from the synchronous render path, so this is a plain method.
"""
...
def get(self, ref: str) -> Optional[str]:
"""Return the value stored for ``ref``, or ``None`` if unknown/expired."""
...
class InMemoryCallbackPayloadStore:
"""Bounded, zero-dependency in-memory :class:`CallbackPayloadStoreProtocol`.
The default store when a channel does not inject a durable one. Entries are
kept until their ``expires_at`` and the store is capped at ``max_entries``
(oldest inserted evicted first) so a long-running process cannot grow
unbounded. This is per-process only; durable, restart-surviving persistence
belongs to a runtime-provided backend (as approvals already do).
"""
def __init__(self, *, max_entries: int = 4096) -> None:
self._max_entries = max(1, int(max_entries))
# ref -> (value, expires_at); insertion-ordered for FIFO eviction.
self._entries: "Dict[str, tuple[str, float]]" = {}
def _purge_expired(self, now: float) -> None:
expired = [ref for ref, (_, exp) in self._entries.items() if exp <= now]
for ref in expired:
self._entries.pop(ref, None)
def put(self, ref: str, value: str, *, expires_at: float) -> None:
now = time.time()
self._purge_expired(now)
# Refresh insertion order on overwrite so it is treated as most-recent.
self._entries.pop(ref, None)
self._entries[ref] = (value, expires_at)
while len(self._entries) > self._max_entries:
oldest = next(iter(self._entries))
self._entries.pop(oldest, None)
def get(self, ref: str) -> Optional[str]:
entry = self._entries.get(ref)
if entry is None:
return None
value, expires_at = entry
if expires_at <= time.time():
self._entries.pop(ref, None)
return None
return value
@@ -5,6 +5,7 @@ Provides publish/subscribe functionality for typed events.
"""
import asyncio
import logging
from praisonaiagents._logging import get_logger
import threading
from typing import Any, Callable, Dict, List, Optional, Set, Union
@@ -7,6 +7,7 @@ Implements shadow git repository for file-level checkpointing.
import os
import re
import asyncio
import logging
from praisonaiagents._logging import get_logger
import shutil
from typing import Optional, List, Dict, Any, Callable
@@ -25,19 +25,6 @@ def __getattr__(name: str):
elif name == "CliBackendDelta":
from .protocols import CliBackendDelta
return CliBackendDelta
elif name == "cli_backend_debug_enabled":
raise AttributeError(
f"module '{__name__}' has no attribute '{name}'. "
"CLI backend logging moved to praisonai-plugins cli_backend_tracer."
)
elif name == "log_cli_backend_execution":
raise AttributeError(
f"module '{__name__}' has no attribute '{name}'. "
"CLI backend logging moved to praisonai-plugins cli_backend_tracer."
)
elif name == "backend_label":
from .debug import backend_label
return backend_label
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -46,6 +33,5 @@ __all__ = [
"CliBackendConfig",
"CliSessionBinding",
"CliBackendResult",
"CliBackendDelta",
"backend_label",
"CliBackendDelta"
]
@@ -1,42 +0,0 @@
"""CLI backend helpers for hooks and observability."""
from __future__ import annotations
from typing import Any
def backend_label(backend: Any) -> str:
"""Human-readable backend identifier for hooks and logs."""
config = getattr(backend, "config", None)
command = getattr(config, "command", None)
if command:
return str(command)
return type(backend).__name__
_PROMPT_FLAGS = frozenset(
{"-p", "--prompt", "-i", "--input", "-m", "--message", "--system"}
)
def redact_command(command: Any) -> Any:
"""Redact prompt/system values from a subprocess argv for safe serialization.
Keeps the executable and flags visible for verification while masking the
value that follows a known prompt-bearing flag, since that value may carry
user prompts or system instructions that should not leak into log sinks.
Non-list inputs are returned unchanged.
"""
if not isinstance(command, (list, tuple)):
return command
redacted = []
mask_next = False
for arg in command:
if mask_next:
redacted.append("<redacted>")
mask_next = False
continue
redacted.append(arg)
if isinstance(arg, str) and arg in _PROMPT_FLAGS:
mask_next = True
return redacted
@@ -160,8 +160,7 @@ class ContextCompactor:
)
except Exception:
pass
from ..context.tokens import estimate_tokens_heuristic
return estimate_tokens_heuristic(text)
return max(1, len(text) // 4)
def count_message_tokens(self, message: Dict[str, Any]) -> int:
"""Count tokens in a message, including tool_calls payloads."""
@@ -8,6 +8,7 @@ The implementation is extracted from workflows.py _evaluate_condition()
to enable DRY condition handling across the codebase.
"""
import re
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, Any, List, Optional
@@ -950,16 +950,7 @@ class ExecutionConfig:
if isinstance(context_compaction, dict):
from ..context.policy import ContextCompactionPolicy
context_compaction = ContextCompactionPolicy.from_dict(context_compaction)
# Handle compaction_strategy restoration (serialised as a string value in to_dict)
compaction_strategy = data.get("compaction_strategy")
if isinstance(compaction_strategy, str):
from ..compaction.strategy import CompactionStrategy
try:
compaction_strategy = CompactionStrategy(compaction_strategy)
except ValueError:
compaction_strategy = None
return cls(
max_iter=data.get("max_iter", 20),
max_steps=data.get("max_steps", None),
@@ -967,17 +958,13 @@ class ExecutionConfig:
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),
retry_initial_delay=data.get("retry_initial_delay", 1.0),
retry_backoff_factor=data.get("retry_backoff_factor", 2.0),
retry_jitter=data.get("retry_jitter", 0.1),
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
code_sandbox_mode=data.get("code_sandbox_mode", "docker"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
max_context_tokens=data.get("max_context_tokens", None),
compaction_strategy=compaction_strategy,
max_budget=data.get("max_budget", None),
parallel_tool_calls=data.get("parallel_tool_calls", False),
)
@@ -1089,13 +1076,7 @@ class ToolConfig:
artifact_retention_days: int = 7 # Days to retain artifacts before garbage collection
artifact_store: Optional[Any] = None # Custom artifact store instance
redact_secrets: bool = True # Whether to redact secrets from artifacts
# Allow resolving tool calls via the process-global @tool registry when a
# tool name is not in this agent's declared tools=[...]. Default False so an
# agent is scoped strictly to its own tools (safe by default); opt in for
# plugin-host style dynamic dispatch.
allow_global_tools: bool = False
def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if self.output_limit <= 0:
@@ -1124,7 +1105,6 @@ class ToolConfig:
"artifact_retention_days": self.artifact_retention_days,
"artifact_store": self.artifact_store,
"redact_secrets": self.redact_secrets,
"allow_global_tools": self.allow_global_tools,
}
@classmethod
@@ -1150,7 +1130,6 @@ class ToolConfig:
artifact_retention_days=data.get("artifact_retention_days", 7),
artifact_store=data.get("artifact_store"),
redact_secrets=data.get("redact_secrets", True),
allow_global_tools=data.get("allow_global_tools", False),
)
@@ -50,6 +50,42 @@ def detect_url_scheme(value: str) -> Optional[str]:
return None
def parse_url_to_config(
url: str,
config_class: type,
url_schemes: Dict[str, str],
) -> Any:
"""
Parse a URL string into a config object.
Args:
url: URL string (e.g., "postgresql://localhost/db")
config_class: Config dataclass to instantiate
url_schemes: Mapping of URL schemes to backend names
Returns:
Config instance with backend and url set
Raises:
ValueError: If URL scheme is not supported
"""
scheme = detect_url_scheme(url)
if not scheme:
raise ValueError(f"Invalid URL format: {url}")
if scheme not in url_schemes:
valid_schemes = ", ".join(sorted(url_schemes.keys()))
raise ValueError(
f"Unsupported URL scheme '{scheme}' in '{url}'. "
f"Supported schemes: {valid_schemes}"
)
backend = url_schemes[scheme]
# Create config with backend and URL in config dict
return config_class(backend=backend, config={"url": url})
def is_path_like(value: str) -> bool:
"""
Check if a string looks like a file path. O(1) operation.
@@ -100,6 +136,21 @@ def is_path_like(value: str) -> bool:
return False
def is_numeric_string(value: str) -> bool:
"""
Check if a string is numeric. O(1) operation.
Args:
value: String to check
Returns:
True if string is numeric
"""
if not isinstance(value, str):
return False
return value.isdigit()
def suggest_similar(value: str, candidates: Iterable[str], max_distance: int = 2) -> Optional[str]:
"""
Find the most similar string from candidates using Levenshtein distance.
@@ -195,6 +246,28 @@ def make_preset_error(
return ValueError(" ".join(msg_parts))
def make_array_error(
param_name: str,
value: list,
expected_format: str,
) -> ValueError:
"""
Create a helpful error message for invalid array format.
Args:
param_name: Name of the parameter
value: Invalid array value
expected_format: Description of expected format
Returns:
ValueError with helpful message
"""
return ValueError(
f"Invalid {param_name} array format: {value}. "
f"Expected: {expected_format}"
)
def is_policy_string(value: str) -> bool:
"""
Check if a string is a policy specification. O(1) operation.
@@ -6,6 +6,7 @@ Inspired by CrewAI's ContextualMemory pattern.
"""
import asyncio
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -12,6 +12,7 @@ Design principles:
"""
import asyncio
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List
@@ -12,6 +12,7 @@ Design principles:
- Preserves most relevant content
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Protocol, Optional, List
from dataclasses import dataclass
@@ -7,6 +7,7 @@ Injects FastContext results into the main agent with:
- Precision over recall (avoid context pollution)
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
@@ -25,6 +25,7 @@ Example usage:
import os
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff. Show more