Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 6c41174888 fix: restore test safety plugins and correct core SDK path in conftest
Register the test_gating and network_guard plugins via
config.pluginmanager.import_plugin() in pytest_configure, which is the
supported mechanism for non-top-level conftest files under pytest 9.1.1+.
This restores test gating and network-guard enforcement that was dropped
when pytest_plugins was removed.

Also fix the core SDK sys.path insertion: praisonaiagents lives at
src/praisonai-agents, so traverse two parents ('..','..') to reach
<root>/src, not three.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 13:43:05 +00:00
276 changed files with 2029 additions and 15875 deletions

No files matched your search

+4 -28
View File
@@ -36,10 +36,8 @@ assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
},
},
};
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseToday(mockGithub, 'o', 'r', noonUtc),
true
);
@@ -48,34 +46,12 @@ assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc),
false
);
assert.ok(result.reasons.some((r) => r.includes('already released today')));
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: hasSuccessfulReleaseWithinDays');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: hasSuccessfulReleaseToday');
console.log('ok: daily dedupe blocks second release');
})();
+6 -28
View File
@@ -5,8 +5,6 @@
const https = require('https');
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
@@ -81,18 +79,8 @@ function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
@@ -101,15 +89,10 @@ async function hasSuccessfulReleaseWithinDays(
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -152,11 +135,9 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
@@ -241,9 +222,6 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
releaseIntervalStart,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+3 -3
View File
@@ -1,12 +1,12 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Triggers: Core Tests success on main (workflow_run) or cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
# Triggers: Core Tests success on main (workflow_run) or nightly cron at 00:00 UTC.
# Dedupe: max one successful patch release per UTC day (see release-gate.js).
on:
schedule:
- cron: '0 0 */3 * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
+1 -3
View File
@@ -134,9 +134,7 @@ jobs:
'pypi-auto' || 'pypi'
}}
env:
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Checkout main
+1 -7
View File
@@ -83,7 +83,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str = '', threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False, goal: Optional[str] = None, goal_criteria: Optional[Any] = None, judge_model: Optional[str] = None) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">set_snapshot_root</a>(project_path: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', action: str = 'add', **kwargs: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>() -> Optional[Any]</code>
@@ -569,7 +568,6 @@ Methods:
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_prompts</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_resources</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_tools</a>() -> List[Callable]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">list_active_server_names</a>() -> set</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">shutdown</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">to_openai_tool</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">with_tool_prefix</a>(prefix: str) -> 'MCP'</code>
@@ -733,7 +731,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, RunOutcome, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -801,16 +799,12 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[crewai]"
+1 -11
View File
@@ -1,15 +1,5 @@
import threading
import time
from praisonaiagents import Agent
if __name__ == "__main__":
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
server = threading.Thread(
target=lambda: agent.launch(port=8080, protocol="mcp", path="/mcp"),
daemon=True,
)
server.start()
time.sleep(2)
print("MCP server started on http://127.0.0.1:8080/mcp")
agent.launch(port=8080, protocol="mcp", path="/mcp")
@@ -150,7 +150,7 @@ def main():
agent = Agent(
name="AI Research Assistant",
instructions="You are an AI research assistant. Use your knowledge base to answer questions about artificial intelligence topics.",
knowledge={"sources": sample_documents, "rerank": True},
knowledge={**rerank_config, "sources": sample_documents},
llm="gpt-4o-mini"
)
@@ -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.")
+18 -18
View File
@@ -14,18 +14,18 @@ def test_imports():
"""Test that all required modules can be imported."""
try:
from praisonaiagents import Agent
print("[OK] Agent imported successfully")
print("✅ Agent imported successfully")
from praisonaiagents.tools import execute_command
print("[OK] execute_command tool imported successfully")
print("✅ execute_command tool imported successfully")
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
print("[OK] Approval system imported successfully")
print("✅ Approval system imported successfully")
return True
except ImportError as e:
print(f"[FAIL] Import failed: {ascii(e)}")
print(f"❌ Import failed: {e}")
return False
def test_agent_creation():
@@ -40,11 +40,11 @@ def test_agent_creation():
)
assert agent.name == 'test-agent'
print("[OK] Agent creation successful")
print("✅ Agent creation successful")
return True
except Exception as e:
print(f"[FAIL] Agent creation failed: {ascii(e)}")
print(f"❌ Agent creation failed: {e}")
return False
def test_approval_system():
@@ -55,11 +55,11 @@ def test_approval_system():
registry = get_approval_registry()
registry.set_backend(AutoApproveBackend())
print("[OK] Approval system configuration successful")
print("✅ Approval system configuration successful")
return True
except Exception as e:
print(f"[FAIL] Approval system test failed: {ascii(e)}")
print(f"❌ Approval system test failed: {e}")
return False
def test_external_agent_import():
@@ -80,11 +80,11 @@ def test_external_agent_import():
# Now try to import our agent
import praisonai_external_agent
print("[OK] External agent import successful")
print("✅ External agent import successful")
return True
except Exception as e:
print(f"[FAIL] External agent import failed: {ascii(e)}")
print(f"❌ External agent import failed: {e}")
return False
def test_multi_agent_import():
@@ -104,11 +104,11 @@ def test_multi_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import multi_agent_example
print("[OK] Multi-agent example import successful")
print("✅ Multi-agent example import successful")
return True
except Exception as e:
print(f"[FAIL] Multi-agent example import failed: {ascii(e)}")
print(f"❌ Multi-agent example import failed: {e}")
return False
def test_wrapper_agent_import():
@@ -128,11 +128,11 @@ def test_wrapper_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import praisonai_wrapper_agent
print("[OK] Wrapper agent import successful (CLI-based approach)")
print("✅ Wrapper agent import successful (CLI-based approach)")
return True
except Exception as e:
print(f"[FAIL] Wrapper agent import failed: {ascii(e)}")
print(f"❌ Wrapper agent import failed: {e}")
return False
if __name__ == "__main__":
@@ -152,19 +152,19 @@ if __name__ == "__main__":
total = len(tests)
for test_name, test_func in tests:
print(f"\n[TEST] {test_name}...")
print(f"\n🧪 Testing {test_name}...")
try:
if test_func():
passed += 1
except Exception as e:
print(f"[FAIL] Test {test_name} crashed: {ascii(e)}")
print(f"❌ Test {test_name} crashed: {e}")
print("\n" + "=" * 55)
print(f"Results: {passed}/{total} tests passed")
if passed == total:
print("[OK] ALL BASIC TESTS PASSED!")
print("🎉 ALL BASIC TESTS PASSED!")
print("The Terminal-Bench integration components are properly structured.")
else:
print("[FAIL] Some tests failed - check output above")
print("❌ Some tests failed - check output above")
sys.exit(1)
+2 -4
View File
@@ -30,11 +30,9 @@ def main():
else:
print(f"Found {len(stories)} top stories:")
for story in stories:
title = story.get("title") or "N/A"
url = story.get("url") or "N/A"
print(f" - {title[:50]}...")
print(f" - {story.get('title', 'N/A')[:50]}...")
print(f" Score: {story.get('score', 'N/A')} | Comments: {story.get('descendants', 'N/A')}")
print(f" URL: {url[:60]}...")
print(f" URL: {story.get('url', 'N/A')[:60]}...")
print()
print("✅ Hacker News tool working correctly!")
+17 -17
View File
@@ -18,39 +18,39 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 46 | 14663 |
| ✅ Agent | 21 | 46 | 14657 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2312 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 10 | 2880 |
| ✅ Autonomy | 3 | 7 | 2707 |
| ✅ Bots | 7 | 31 | 12353 |
| ✅ Bots | 7 | 29 | 11795 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 119 | 34537 |
| ✅ CLI | 5 | 119 | 34087 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 4031 |
| ✅ 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 |
| ✅ 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 | 56 | 20195 |
| ✅ Guardrails | 4 | 4 | 1673 |
| ✅ Handoffs | 11 | 6 | 2454 |
| ✅ Hooks | 2 | 9 | 4128 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 13 | 4160 |
| ✅ LLM | 3 | 14 | 4864 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ Loops | 4 | 4 | 1202 |
| ✅ MCP | 1 | 58 | 13322 |
| ✅ Memory | 6 | 17 | 6635 |
| ✅ OCR | 2 | 1 | 237 |
@@ -60,7 +60,7 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1545 |
| ✅ Plugins | 8 | 3 | 1900 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Prompts | 2 | 9 | 1913 |
| ✅ Providers | 1 | 55 | 7502 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 2936 |
@@ -68,14 +68,14 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1044 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 6 | 2766 |
| ✅ Sandbox | 5 | 6 | 2700 |
| ✅ Security | 1 | 3 | 2970 |
| ✅ Sessions | 4 | 13 | 5302 |
| ✅ Skills | 6 | 13 | 4998 |
| ✅ Tasks | 2 | 6 | 2809 |
| ✅ 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 | 133 | 37934 |
| ✅ 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.
@@ -2230,10 +2230,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 +3198,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.
@@ -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__)
@@ -2462,7 +2462,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 +3064,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 +3733,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
@@ -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):
"""
@@ -1783,7 +1671,7 @@ Write the complete compiled report:"""
base_path = (path or "/mcp").rstrip("/") or "/mcp"
transport = SseServerTransport(f"{base_path}/sse")
starlette_app = Starlette(
routes=[Mount(base_path, mcp.sse_app())]
routes=[Mount(base_path, mcp.create_app())]
)
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":
@@ -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(
@@ -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) ─────────────────────────────────
@@ -387,17 +346,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 +381,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 +406,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 +437,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,
)
@@ -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
@@ -33,9 +33,6 @@ from .protocols import (
WebhookVerifierProtocol,
CallbackPayloadStoreProtocol,
InMemoryCallbackPayloadStore,
GatewayRuntimeSeams,
SupportsGatewayRuntime,
GatewayAdapterContractError,
)
from .base import (
BasePlatformAdapter,
@@ -125,9 +122,6 @@ __all__ = [
"WebhookVerifierProtocol",
"CallbackPayloadStoreProtocol",
"InMemoryCallbackPayloadStore",
"GatewayRuntimeSeams",
"SupportsGatewayRuntime",
"GatewayAdapterContractError",
"BasePlatformAdapter",
"SendResult",
"InteractiveContext",
@@ -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."""
@@ -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
@@ -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),
)
@@ -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
@@ -10,6 +10,7 @@ This agent is designed for fast context retrieval using:
import os
import json
import logging
from praisonaiagents._logging import get_logger
from typing import List, Dict, Any, Optional
@@ -15,6 +15,7 @@ Design principles:
import os
import json
import hashlib
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from pathlib import Path
@@ -12,6 +12,7 @@ import os
import fnmatch
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass
from typing import Dict, List, Set, Optional, Any
@@ -0,0 +1,318 @@
"""
File Watcher for Incremental Indexing.
Monitors file system changes and triggers incremental index updates:
- Watches for file creation, modification, deletion
- Debounces rapid changes
- Supports background indexing
"""
import os
import time
import logging
from praisonaiagents._logging import get_logger
import threading
from typing import Dict, Set, Callable, Optional, Any
from dataclasses import dataclass
from enum import Enum
logger = get_logger(__name__)
class ChangeType(Enum):
"""Types of file system changes."""
CREATED = "created"
MODIFIED = "modified"
DELETED = "deleted"
@dataclass
class FileChange:
"""Represents a file system change.
Attributes:
path: Path to the changed file
change_type: Type of change
timestamp: When the change was detected
"""
path: str
change_type: ChangeType
timestamp: float
class FileWatcher:
"""Watches for file system changes and triggers callbacks.
Uses polling-based approach for cross-platform compatibility.
For production use, consider using watchdog library.
Attributes:
workspace_path: Root directory to watch
extensions: File extensions to watch
poll_interval: Seconds between polls
"""
def __init__(
self,
workspace_path: str,
extensions: Optional[Set[str]] = None,
poll_interval: float = 1.0,
debounce_delay: float = 0.5,
on_change: Optional[Callable[[FileChange], None]] = None
):
"""Initialize file watcher.
Args:
workspace_path: Root directory to watch
extensions: File extensions to watch (None = all)
poll_interval: Seconds between polls
debounce_delay: Delay before processing changes
on_change: Callback for file changes
"""
self.workspace_path = os.path.abspath(workspace_path)
self.extensions = extensions or {
'.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java',
'.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift'
}
self.poll_interval = poll_interval
self.debounce_delay = debounce_delay
self.on_change = on_change
# State tracking
self._file_mtimes: Dict[str, float] = {}
self._pending_changes: Dict[str, FileChange] = {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _scan_files(self) -> Dict[str, float]:
"""Scan workspace and get file modification times.
Returns:
Dict mapping file paths to modification times
"""
mtimes = {}
for root, dirs, files in os.walk(self.workspace_path):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.')]
for filename in files:
ext = os.path.splitext(filename)[1].lower()
if ext in self.extensions:
file_path = os.path.join(root, filename)
try:
mtimes[file_path] = os.path.getmtime(file_path)
except OSError:
pass
return mtimes
def _detect_changes(self, new_mtimes: Dict[str, float]) -> None:
"""Detect changes between old and new file states.
Args:
new_mtimes: New file modification times
"""
old_paths = set(self._file_mtimes.keys())
new_paths = set(new_mtimes.keys())
now = time.time()
# Detect created files
for path in new_paths - old_paths:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.CREATED,
timestamp=now
))
# Detect deleted files
for path in old_paths - new_paths:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.DELETED,
timestamp=now
))
# Detect modified files
for path in old_paths & new_paths:
if new_mtimes[path] > self._file_mtimes[path]:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.MODIFIED,
timestamp=now
))
self._file_mtimes = new_mtimes
def _add_pending_change(self, change: FileChange) -> None:
"""Add a change to pending queue (with debouncing).
Args:
change: File change to add
"""
with self._lock:
self._pending_changes[change.path] = change
def _process_pending_changes(self) -> None:
"""Process pending changes after debounce delay."""
with self._lock:
now = time.time()
to_process = []
for path, change in list(self._pending_changes.items()):
if now - change.timestamp >= self.debounce_delay:
to_process.append(change)
del self._pending_changes[path]
# Process changes outside lock
for change in to_process:
if self.on_change:
try:
self.on_change(change)
except Exception as e:
logger.error(f"Error processing change {change.path}: {e}")
def _poll_loop(self) -> None:
"""Main polling loop."""
# Initial scan
self._file_mtimes = self._scan_files()
while self._running:
try:
# Scan for changes
new_mtimes = self._scan_files()
self._detect_changes(new_mtimes)
# Process pending changes
self._process_pending_changes()
except Exception as e:
logger.error(f"Error in file watcher: {e}")
time.sleep(self.poll_interval)
def start(self) -> None:
"""Start watching for file changes."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
logger.info(f"File watcher started for {self.workspace_path}")
def stop(self) -> None:
"""Stop watching for file changes."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info("File watcher stopped")
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
class IncrementalIndexer:
"""Manages incremental index updates based on file changes.
Combines FileIndexer/SymbolIndexer with FileWatcher for
automatic index updates when files change.
"""
def __init__(
self,
workspace_path: str,
file_indexer: Optional[Any] = None,
symbol_indexer: Optional[Any] = None,
poll_interval: float = 2.0
):
"""Initialize incremental indexer.
Args:
workspace_path: Root directory to index
file_indexer: FileIndexer instance (optional)
symbol_indexer: SymbolIndexer instance (optional)
poll_interval: Seconds between file system polls
"""
self.workspace_path = os.path.abspath(workspace_path)
self.file_indexer = file_indexer
self.symbol_indexer = symbol_indexer
self._watcher = FileWatcher(
workspace_path=workspace_path,
poll_interval=poll_interval,
on_change=self._handle_change
)
# Statistics
self.changes_processed = 0
self.last_change_time: Optional[float] = None
def _handle_change(self, change: FileChange) -> None:
"""Handle a file change.
Args:
change: File change to process
"""
rel_path = os.path.relpath(change.path, self.workspace_path)
logger.debug(f"Processing {change.change_type.value}: {rel_path}")
if change.change_type == ChangeType.DELETED:
# Remove from indexes
if self.file_indexer and hasattr(self.file_indexer, 'files'):
self.file_indexer.files.pop(rel_path, None)
if self.symbol_indexer and hasattr(self.symbol_indexer, 'symbols'):
self.symbol_indexer.symbols.pop(rel_path, None)
else:
# Re-index the file
if self.file_indexer and hasattr(self.file_indexer, 'index_file'):
# FileIndexer doesn't have index_file, so we skip
pass
if self.symbol_indexer and hasattr(self.symbol_indexer, 'index_file'):
self.symbol_indexer.index_file(change.path)
self.changes_processed += 1
self.last_change_time = time.time()
def start(self) -> None:
"""Start incremental indexing."""
# Initial full index
if self.file_indexer:
self.file_indexer.index()
if self.symbol_indexer:
self.symbol_indexer.index()
# Start watching
self._watcher.start()
def stop(self) -> None:
"""Stop incremental indexing."""
self._watcher.stop()
def get_stats(self) -> Dict[str, Any]:
"""Get indexer statistics.
Returns:
Dictionary with stats
"""
stats = {
"changes_processed": self.changes_processed,
"last_change_time": self.last_change_time
}
if self.file_indexer:
stats["file_indexer"] = self.file_indexer.get_stats()
if self.symbol_indexer:
stats["symbol_indexer"] = self.symbol_indexer.get_stats()
return stats
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
@@ -11,6 +11,7 @@ Extracts code symbols from source files:
import os
import re
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field
from typing import Dict, List, Set, Optional, Any
@@ -11,6 +11,7 @@ Provides async parallel execution of search tools with:
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Any, Optional, Callable
import logging
from praisonaiagents._logging import get_logger
import time
@@ -11,6 +11,7 @@ Design principles:
- No performance impact on default behavior
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Protocol, List, Dict, Any, Optional, runtime_checkable
@@ -0,0 +1,366 @@
"""
Search Strategy for Fast Context.
Orchestrates multi-turn search with:
- Turn 1-3: Exploration (grep, glob, directory listing)
- Turn 4: Final answer (file list + line ranges)
- Early termination if sufficient context found
"""
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Set
from enum import Enum
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
logger = get_logger(__name__)
class SearchPhase(Enum):
"""Phases of the search strategy."""
DISCOVERY = "discovery" # Turn 1: Discover structure
EXPLORATION = "exploration" # Turn 2-3: Deep search
REFINEMENT = "refinement" # Turn 4: Final answer
@dataclass
class SearchState:
"""Tracks the state of a multi-turn search.
Attributes:
query: Original search query
current_turn: Current turn number (1-indexed)
max_turns: Maximum turns allowed
phase: Current search phase
discovered_files: Files found so far
explored_paths: Paths already explored
result: Accumulated search result
should_terminate: Whether to terminate early
"""
query: str
current_turn: int = 0
max_turns: int = 4
phase: SearchPhase = SearchPhase.DISCOVERY
discovered_files: Set[str] = field(default_factory=set)
explored_paths: Set[str] = field(default_factory=set)
result: FastContextResult = field(default_factory=FastContextResult)
should_terminate: bool = False
# Thresholds for early termination
min_files_for_termination: int = 5
max_files_before_refinement: int = 20
def advance_turn(self) -> None:
"""Advance to the next turn and update phase."""
self.current_turn += 1
if self.current_turn == 1:
self.phase = SearchPhase.DISCOVERY
elif self.current_turn < self.max_turns:
self.phase = SearchPhase.EXPLORATION
else:
self.phase = SearchPhase.REFINEMENT
def check_early_termination(self) -> bool:
"""Check if search should terminate early.
Returns:
True if search should terminate
"""
# Terminate if we have enough high-quality results
if len(self.discovered_files) >= self.min_files_for_termination:
high_relevance = sum(
1 for f in self.result.files
if f.relevance_score >= 0.8
)
if high_relevance >= 3:
self.should_terminate = True
return True
# Terminate if we've explored everything
if self.current_turn >= 2 and not self._has_unexplored_paths():
self.should_terminate = True
return True
return False
def _has_unexplored_paths(self) -> bool:
"""Check if there are unexplored paths."""
# Simple heuristic: if we found files but haven't explored many paths
return len(self.discovered_files) > len(self.explored_paths) * 2
class SearchStrategy:
"""Orchestrates multi-turn search strategy.
Implements the Fast Context search approach:
- Turn 1: Discovery - understand codebase structure
- Turn 2-3: Exploration - deep search for relevant code
- Turn 4: Refinement - finalize results
Supports early termination when sufficient context is found.
"""
def __init__(
self,
workspace_path: str,
max_turns: int = 4,
max_parallel: int = 8
):
"""Initialize search strategy.
Args:
workspace_path: Root directory for searches
max_turns: Maximum search turns
max_parallel: Maximum parallel tool calls per turn
"""
self.workspace_path = workspace_path
self.max_turns = max_turns
self.max_parallel = max_parallel
def create_state(self, query: str) -> SearchState:
"""Create a new search state.
Args:
query: Search query
Returns:
New SearchState instance
"""
state = SearchState(query=query, max_turns=self.max_turns)
state.result.query = query
return state
def plan_discovery_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan the discovery turn (Turn 1).
Focus on understanding codebase structure:
- List root directory
- Find common file patterns
- Initial grep for query terms
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
query = state.query
# List root directory
batch.add("list_directory", dir_path=self.workspace_path, recursive=False)
# Find Python files (most common)
batch.add("glob_search", search_path=self.workspace_path, pattern="**/*.py", max_results=20)
# Initial grep for query terms
query_words = query.lower().split()
for word in query_words[:3]: # First 3 words
if len(word) >= 3: # Skip short words
batch.add("grep_search", search_path=self.workspace_path, pattern=word, max_results=10)
if batch.is_full:
break
return batch
def plan_exploration_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan an exploration turn (Turn 2-3).
Deep search based on discovery results:
- Search in discovered directories
- Read promising files
- Follow references
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
# Read top files found so far
for file_match in state.result.files[:4]:
if file_match.path not in state.explored_paths:
batch.add("read_file", filepath=file_match.path, context_lines=5)
state.explored_paths.add(file_match.path)
if batch.is_full:
break
# Search in unexplored directories
unexplored_dirs = self._find_unexplored_dirs(state)
for dir_path in unexplored_dirs[:2]:
batch.add("list_directory", dir_path=dir_path, recursive=True, max_depth=2)
if batch.is_full:
break
# Additional grep searches with refined patterns
if not batch.is_full:
refined_patterns = self._generate_refined_patterns(state)
for pattern in refined_patterns[:2]:
batch.add("grep_search", search_path=self.workspace_path, pattern=pattern, max_results=15)
if batch.is_full:
break
return batch
def plan_refinement_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan the refinement turn (Turn 4).
Finalize results:
- Read remaining important files
- Get specific line ranges
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
# Read top files that haven't been fully explored
for file_match in state.result.files[:self.max_parallel]:
if file_match.path not in state.explored_paths:
# Read specific line ranges if available
if file_match.line_ranges:
for lr in file_match.line_ranges[:2]:
batch.add(
"read_file",
filepath=file_match.path,
start_line=max(1, lr.start - 5),
end_line=lr.end + 5
)
if batch.is_full:
break
else:
batch.add("read_file", filepath=file_match.path)
state.explored_paths.add(file_match.path)
if batch.is_full:
break
return batch
def plan_next_turn(self, state: SearchState) -> Optional[ToolCallBatch]:
"""Plan the next turn based on current state.
Args:
state: Current search state
Returns:
Batch of tool calls, or None if search should end
"""
state.advance_turn()
# Check for early termination
if state.should_terminate or state.current_turn > state.max_turns:
return None
if state.check_early_termination():
return None
# Plan based on phase
if state.phase == SearchPhase.DISCOVERY:
return self.plan_discovery_turn(state)
elif state.phase == SearchPhase.EXPLORATION:
return self.plan_exploration_turn(state)
else: # REFINEMENT
return self.plan_refinement_turn(state)
def process_results(
self,
state: SearchState,
tool_results: List[Any]
) -> None:
"""Process results from a turn and update state.
Args:
state: Current search state
tool_results: Results from tool execution
"""
for result in tool_results:
if isinstance(result, list):
# Grep or glob results
for item in result:
if isinstance(item, dict):
path = item.get("path", "")
if path:
state.discovered_files.add(path)
# Create or update file match
file_match = FileMatch(
path=path,
relevance_score=0.8,
match_count=1
)
# Add line range if available
line_num = item.get("line_number")
if line_num:
file_match.add_line_range(LineRange(
start=max(1, line_num - 2),
end=line_num + 2,
content=item.get("context"),
relevance_score=0.8
))
state.result.add_file(file_match)
elif isinstance(result, dict):
# Read file or list directory result
if result.get("success"):
path = result.get("path", "")
if path:
state.explored_paths.add(path)
# Handle directory listing
entries = result.get("entries", [])
for entry in entries:
if not entry.get("is_dir"):
state.discovered_files.add(entry.get("path", ""))
def _find_unexplored_dirs(self, state: SearchState) -> List[str]:
"""Find directories that haven't been explored.
Args:
state: Current search state
Returns:
List of unexplored directory paths
"""
import os
# Extract unique directories from discovered files
dirs = set()
for file_path in state.discovered_files:
dir_path = os.path.dirname(file_path)
if dir_path and dir_path not in state.explored_paths:
dirs.add(dir_path)
return list(dirs)[:5] # Limit to 5
def _generate_refined_patterns(self, state: SearchState) -> List[str]:
"""Generate refined search patterns based on results.
Args:
state: Current search state
Returns:
List of refined search patterns
"""
patterns = []
query_words = state.query.lower().split()
# Combine query words
if len(query_words) >= 2:
patterns.append(f"{query_words[0]}.*{query_words[1]}")
# Add common code patterns
for word in query_words[:2]:
if len(word) >= 3:
patterns.append(f"def {word}")
patterns.append(f"class {word}")
return patterns[:4]
@@ -15,6 +15,7 @@ import re
import fnmatch
from pathlib import Path
from typing import List, Dict, Any, Optional, Set
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
import json
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -18,6 +18,7 @@ Zero Performance Impact:
import threading
import json
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional
from enum import Enum
import time
import hashlib
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -5,6 +5,7 @@ Provides lightweight tracing and metrics collection for escalation execution.
Opt-in only - no overhead when not enabled.
"""
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -4,6 +4,7 @@ Escalation Pipeline for PraisonAI Agents.
Implements progressive escalation from direct response to full autonomous mode.
"""
import logging
from praisonaiagents._logging import get_logger
import time
from typing import Any, Dict, List, Optional, Callable
@@ -5,6 +5,7 @@ Evaluates agent output accuracy by comparing against expected output using LLM-a
"""
import os
import logging
from praisonaiagents._logging import get_logger
from typing import Callable, Optional, Union, TYPE_CHECKING
@@ -7,6 +7,7 @@ This module provides the abstract base class that all evaluators inherit from.
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Union, TYPE_CHECKING
import uuid
import logging
from praisonaiagents._logging import get_logger
if TYPE_CHECKING:
@@ -5,6 +5,7 @@ Evaluates agent outputs against custom criteria using LLM-as-judge.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from typing import Callable, Literal, Optional, Union, TYPE_CHECKING
@@ -9,6 +9,7 @@ that was duplicated across multiple evaluators.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
@@ -15,6 +15,7 @@ Example:
"""
import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
@@ -18,6 +18,7 @@ Example:
import time
import math
import logging
from praisonaiagents._logging import get_logger
from typing import TYPE_CHECKING, Optional, Callable, Any, List
from dataclasses import dataclass
@@ -7,6 +7,7 @@ and file-based verification for audio/video.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field
from typing import Any, Dict, Literal, Optional
@@ -5,6 +5,7 @@ Measures runtime and memory usage of agent executions or functions.
"""
import time
import logging
from praisonaiagents._logging import get_logger
from typing import Callable, Optional, TYPE_CHECKING
@@ -4,6 +4,7 @@ Reliability evaluator for PraisonAI Agents.
Evaluates agent reliability by verifying expected tool calls are made.
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Set, Any, TYPE_CHECKING
@@ -18,6 +18,7 @@ Example:
... # Split into chunks
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, Optional, Union
@@ -3,6 +3,7 @@ Utility functions for PraisonAI Agents evaluation framework.
"""
import json
import logging
from praisonaiagents._logging import get_logger
from pathlib import Path
from typing import Any, Dict, Optional, Union
@@ -70,10 +70,6 @@ from .protocols import (
GatewayConcurrencyPolicyProtocol,
GatewayConcurrencyPolicy, # backward-compat alias
ConcurrencyLimitPolicy,
# Gateway resource-pressure admission (Issue #3445)
ResourceSample,
ResourcePressurePolicyProtocol,
MemoryPressurePolicy,
# Gateway rate-limit admission (Issue #2532)
RateLimitDecision,
RateLimitPolicyProtocol,
@@ -281,10 +277,6 @@ __all__ = [
"GatewayConcurrencyPolicyProtocol",
"GatewayConcurrencyPolicy",
"ConcurrencyLimitPolicy",
# Gateway resource-pressure admission (Issue #3445)
"ResourceSample",
"ResourcePressurePolicyProtocol",
"MemoryPressurePolicy",
# Gateway rate-limit admission (Issue #2532)
"RateLimitDecision",
"RateLimitPolicyProtocol",
@@ -63,9 +63,6 @@ class SessionConfig:
max_messages: Maximum messages to keep in history (0 = unlimited)
persist: Whether to persist session state
persist_path: Path for session persistence
store: Persistence backend when ``persist`` is set — ``"sqlite"``
(default: transcripts in a WAL SQLite DB with concurrent readers
and indexed lookups) or ``"file"`` (legacy per-session JSON files)
resume_window: How long (seconds) a session stays resumable after disconnect
max_inbox: Maximum queued messages per session (0 = unlimited, default 256)
metadata: Additional session metadata
@@ -76,7 +73,6 @@ class SessionConfig:
max_messages: int = 1000
persist: bool = False
persist_path: Optional[str] = None
store: str = "sqlite" # "sqlite" (concurrent, indexed) | "file" (legacy JSON)
resume_window: int = 86400 # 24 hours default
max_inbox: int = 256 # Default bounded queue size
metadata: Dict[str, Any] = field(default_factory=dict)
@@ -94,10 +90,6 @@ class SessionConfig:
raise ValueError("max_messages must be >= 0")
if self.resume_window < 0:
raise ValueError("resume_window must be >= 0")
if self.store not in ("sqlite", "file"):
raise ValueError(
f"Invalid session store {self.store!r}; expected 'sqlite' or 'file'"
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -106,7 +98,6 @@ class SessionConfig:
"max_messages": self.max_messages,
"persist": self.persist,
"persist_path": self.persist_path,
"store": self.store,
"resume_window": self.resume_window,
"max_inbox": self.max_inbox,
"metadata": self.metadata,
@@ -694,7 +685,6 @@ class MultiChannelGatewayConfig:
max_messages=sc_data.get("max_messages", 1000),
persist=sc_data.get("persist", False),
persist_path=sc_data.get("persist_path"),
store=sc_data.get("store", "sqlite"),
resume_window=sc_data.get("resume_window", 86400),
max_inbox=sc_data.get("max_inbox", 256),
metadata=sc_data.get("metadata", {}),
@@ -2928,135 +2928,6 @@ class ConcurrencyLimitPolicy:
GatewayConcurrencyPolicy = GatewayConcurrencyPolicyProtocol
# ---------------------------------------------------------------------------
# Gateway resource-pressure admission (Issue #3445)
#
# Admission today is concurrency/CPU-scaled and blind to memory; on a small
# always-on host a burst of concurrent turns drives RSS up until the OOM
# killer fires — the failure a $5 box hits first. This adds a pure, import-
# free decision that maps a resource *sample* (current RSS) onto the existing
# :class:`AdmissionDecision` so the gate can queue under soft pressure and
# shed under hard pressure *before* the process is killed. It reuses the
# admission seam (no new subsystem) and sits beside the concurrency / rate-
# limit / scale-to-zero policy family. The live sampler (reading
# ``resource.getrusage`` / optional ``psutil``) and the wiring into the gate
# live in the wrapper; this contract keeps the decision testable in isolation.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ResourceSample:
"""A point-in-time snapshot of the gateway process's resource usage.
Attributes:
rss_mb: Resident set size in mebibytes, or ``None`` when the platform
cannot report it (the policy then admits and self-disables so the
monitor never crashes the gateway it protects).
"""
rss_mb: Optional[float] = None
@runtime_checkable
class ResourcePressurePolicyProtocol(Protocol):
"""Protocol for memory/resource-aware admission decisions.
Pure, import-free decision contract consumed by the wrapper's admission
gate. The wrapper samples its own resource usage on a lightweight cadence
and hands the policy a :class:`ResourceSample`; the policy returns an
:class:`AdmissionDecision` — ``ADMIT`` below the soft threshold, ``QUEUE``
to apply backpressure above it, and ``REJECT`` above the hard threshold so
the process sheds load before the OOM killer fires. Sampling and
enforcement (the ``asyncio.Semaphore`` ceiling and bounded wait queue)
live in the wrapper; this keeps the *decision* provable in isolation,
symmetric with :class:`GatewayConcurrencyPolicyProtocol`.
A config-driven default (:class:`MemoryPressurePolicy`) is provided for
the common "soft/hard RSS threshold" case.
"""
def evaluate(self, sample: ResourceSample) -> AdmissionDecision:
"""Return an :class:`AdmissionDecision` for the supplied sample."""
...
class MemoryPressurePolicy:
"""Config-driven RSS-threshold resource-pressure policy.
The default wired by the ``max_rss_mb`` gateway config key
(``BotOS(max_rss_mb=...)`` / ``gateway.yaml``) and the
``AdmissionGate(resource_policy=...)`` Python surface. It is intentionally
minimal and dependency-free so the decision lives in core and is provable
in isolation; the wrapper owns the live sampler and the side effects
(queue / shed / busy ack).
The decision, given a sample's ``rss_mb``:
* ``ADMIT`` while ``rss_mb < soft_rss_mb`` (or the platform can't report
memory, so ``rss_mb is None`` — never block on a missing signal).
* ``QUEUE`` (apply backpressure) while ``soft_rss_mb <= rss_mb <
hard_rss_mb``.
* ``REJECT`` (shed with a busy ack) while ``rss_mb >= hard_rss_mb``.
A ``hard_rss_mb`` of ``0`` disables pressure-based shedding entirely
(every sample admits) — the legacy default when no threshold is set.
Example::
MemoryPressurePolicy(soft_rss_mb=400, hard_rss_mb=550)
"""
def __init__(
self,
soft_rss_mb: float = 0.0,
hard_rss_mb: float = 0.0,
):
try:
soft = float(soft_rss_mb or 0.0)
except (TypeError, ValueError) as err:
raise ValueError(
f"soft_rss_mb must be a number, got {soft_rss_mb!r}"
) from err
try:
hard = float(hard_rss_mb or 0.0)
except (TypeError, ValueError) as err:
raise ValueError(
f"hard_rss_mb must be a number, got {hard_rss_mb!r}"
) from err
if soft < 0:
raise ValueError(f"soft_rss_mb must be >= 0, got {soft_rss_mb!r}")
if hard < 0:
raise ValueError(f"hard_rss_mb must be >= 0, got {hard_rss_mb!r}")
# A soft threshold above the hard one would queue turns that should be
# shed; fail fast rather than silently invert the pressure ladder.
if soft and hard and soft > hard:
raise ValueError(
f"soft_rss_mb ({soft_rss_mb!r}) must be <= "
f"hard_rss_mb ({hard_rss_mb!r})"
)
self.soft_rss_mb = soft
self.hard_rss_mb = hard
@property
def enabled(self) -> bool:
"""Whether pressure-based admission is active (a threshold is set)."""
return self.soft_rss_mb > 0 or self.hard_rss_mb > 0
def evaluate(self, sample: ResourceSample) -> AdmissionDecision:
# Disabled, or the platform can't report memory: never block on a
# missing/absent signal — admit and let concurrency limits apply.
if not self.enabled:
return AdmissionDecision.ADMIT
rss = getattr(sample, "rss_mb", None)
if rss is None:
return AdmissionDecision.ADMIT
if self.hard_rss_mb and rss >= self.hard_rss_mb:
return AdmissionDecision.REJECT
if self.soft_rss_mb and rss >= self.soft_rss_mb:
return AdmissionDecision.QUEUE
return AdmissionDecision.ADMIT
# ---------------------------------------------------------------------------
# Gateway rate-limit admission (Issue #2532)
#
@@ -5,6 +5,7 @@ This module provides LLM-powered guardrails that can validate task outputs
using natural language descriptions, similar to CrewAI's implementation.
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Any, Tuple, Union, Optional, Dict
from pydantic import BaseModel
@@ -0,0 +1,13 @@
"""Hooks query protocol for UI/API consumers."""
from __future__ import annotations
from typing import Dict, List, Protocol, runtime_checkable
@runtime_checkable
class HooksQueryProtocol(Protocol):
"""Read path for registered hooks."""
def list_hooks_for_api(self) -> List[Dict]:
...
@@ -4,6 +4,7 @@ Hook Registry for PraisonAI Agents.
Manages registration and lookup of hooks for different events.
"""
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Callable, Union
@@ -9,6 +9,7 @@ import os
import json
import time
import asyncio
import logging
from praisonaiagents._logging import get_logger
import subprocess
from typing import List, Optional, Dict, Any
@@ -36,12 +36,6 @@ class HookEvent(str, Enum):
# LLM lifecycle
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
# Prompt-cache stability (advisory): fired when a turn's cached prompt
# prefix (model + tool schemas + system-prompt fingerprint) changes from
# the previous turn, so a long-lived conversation's provider prompt cache
# will miss. Payload carries the old/new signature and a reason.
PROMPT_PREFIX_INVALIDATED = "prompt_prefix_invalidated"
# Error handling
ON_ERROR = "on_error"
@@ -7,6 +7,7 @@ Handles all normalization to ensure metadata is ALWAYS a dict (never None).
LAZY IMPORT: mem0 is only imported when this adapter is instantiated.
"""
import logging
from praisonaiagents._logging import get_logger
import os
from typing import Any, Dict, List, Optional
@@ -8,6 +8,7 @@ LAZY IMPORT: pymongo is only imported when this adapter is instantiated.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -10,6 +10,7 @@ Implementations are provided by the wrapper layer.
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
import math
@@ -10,6 +10,7 @@ Implementations are provided by the wrapper layer.
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -10,6 +10,7 @@ Implementations are provided by the wrapper layer (praisonai.adapters.readers).
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import os
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -9,6 +9,7 @@ Implementations are provided by praisonai-tools or wrapper layer.
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -10,6 +10,7 @@ Implementations are provided by the wrapper layer.
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -9,6 +9,7 @@ Implementations are provided by praisonai-tools or wrapper layer.
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -10,6 +10,7 @@ Heavy implementations live in the praisonai wrapper.
from __future__ import annotations
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -714,6 +714,30 @@ Respond with ONLY a valid JSON tool call in this format:
return any(indicator in error_str or indicator in error_type for indicator in indicators)
def _classify_error_and_should_retry_legacy(self, error: Exception, attempt: int = 1) -> tuple[str, bool, float]:
"""Legacy error classification - deprecated, use resolve_failover_decision() instead.
Args:
error: Exception to classify
attempt: Current attempt number (1-based)
Returns:
Tuple of (category, should_retry, retry_delay)
"""
import warnings
warnings.warn(
"_classify_error_and_should_retry is deprecated, use resolve_failover_decision() instead",
DeprecationWarning,
stacklevel=2
)
# Delegate to new typed classification system
decision = self.resolve_failover_decision(error, {"attempt": attempt, "max_retries": self._max_retries})
return decision.reason, decision.is_retryable, decision.backoff_ms / 1000.0
# Backward compatibility alias for existing code
_classify_error_and_should_retry = _classify_error_and_should_retry_legacy
def classify_error_kind(self, error: Exception) -> AgentErrorKind:
"""
Classify error into typed AgentErrorKind instead of freeform strings.
@@ -6,6 +6,7 @@ LLM model/provider based on task complexity, cost considerations, and model capa
"""
import os
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
@@ -23,6 +23,7 @@ Usage:
import time
import asyncio
import logging
import threading
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field
@@ -7,6 +7,7 @@ Provides Language Server Protocol client implementation.
import os
import json
import asyncio
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
from pathlib import Path
@@ -282,20 +282,7 @@ class MCP:
agent.start("What is the stock price of Tesla?")
```
"""
# Process-level registry of sanitized MCP server names that have been
# namespaced via with_tool_prefix(), mirroring how tools/registry.py tracks
# tool names. Lets skills' CapabilityValidator discover connected servers
# instead of always failing closed (issue #3307).
_active_server_names: set = set()
_active_server_names_lock = threading.Lock()
@classmethod
def list_active_server_names(cls) -> set:
"""Return the set of sanitized names of MCP servers namespaced this run."""
with cls._active_server_names_lock:
return set(cls._active_server_names)
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False,
allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None, **kwargs):
"""
@@ -847,15 +834,6 @@ class MCP:
self._tool_prefix = sanitized
# Record this server in the process-level registry so skills'
# CapabilityValidator can discover it (issue #3307). Store both the
# original name and its sanitized form so a skill requirement matches
# regardless of which spelling it declares.
with type(self)._active_server_names_lock:
if prefix:
type(self)._active_server_names.add(prefix)
type(self)._active_server_names.add(sanitized)
# Rename already-generated callable tools. Dispatch inside each
# wrapper closes over the original tool name, so only the public
# __name__/__qualname__ needs updating for schema construction.
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more