mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4140e499da |
No files matched your search
@@ -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');
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
CI Integration Example - Using Doctor in CI/CD pipelines
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
PraisonAI Endpoints Example
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Custom Tools MCP Server Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
HTTP Stream MCP Server Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
MCP Client Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Context Agent Example - Basic Usage
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
-1
@@ -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,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Example: LLM-based Recipes
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
HTTP Recipe Registry Example
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
A2A (Agent-to-Agent) Server and Client Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
A2U (Agent-to-User) Event Stream Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Multi-Agent Router as HTTP API Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Unified Endpoints Client Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
MCP HTTP Server Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
PraisonAI Recipe Server Example
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# praisonai: skip=true
|
||||
#!/usr/bin/env python3
|
||||
# praisonai: skip=true
|
||||
"""
|
||||
Tools as MCP Server Example
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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!")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
Reference in new issue
Block a user