mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
172
Commits
No files matched your search
@@ -36,8 +36,10 @@ assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
|
||||
|
||||
assert.strictEqual(
|
||||
await rg.hasSuccessfulReleaseToday(mockGithub, 'o', 'r', noonUtc),
|
||||
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -46,12 +48,34 @@ 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('already released today')));
|
||||
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
|
||||
);
|
||||
|
||||
console.log('ok: bumpPatch');
|
||||
console.log('ok: readVersionsFromTree');
|
||||
console.log('ok: pypiVersionExists missing version');
|
||||
console.log('ok: utcDayStart');
|
||||
console.log('ok: hasSuccessfulReleaseToday');
|
||||
console.log('ok: daily dedupe blocks second release');
|
||||
console.log('ok: hasSuccessfulReleaseWithinDays');
|
||||
console.log('ok: 3-day dedupe blocks second release');
|
||||
})();
|
||||
@@ -5,6 +5,8 @@
|
||||
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',
|
||||
]);
|
||||
@@ -79,8 +81,18 @@ function utcDayStart(now = new Date()) {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
const dayStart = utcDayStart(now);
|
||||
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);
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
@@ -89,10 +101,15 @@ async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date())
|
||||
per_page: 30,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
|
||||
);
|
||||
}
|
||||
|
||||
/** @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,
|
||||
@@ -135,9 +152,11 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
}
|
||||
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
|
||||
const day = referenceTime.toISOString().slice(0, 10);
|
||||
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
|
||||
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`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -222,6 +241,9 @@ module.exports = {
|
||||
bumpPatch,
|
||||
readVersionsFromTree,
|
||||
pypiVersionExists,
|
||||
PATCH_RELEASE_INTERVAL_DAYS,
|
||||
releaseIntervalStart,
|
||||
hasSuccessfulReleaseWithinDays,
|
||||
hasSuccessfulReleaseToday,
|
||||
utcDayStart,
|
||||
evaluateReleasePreflight,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: Nightly Release Gate
|
||||
|
||||
# Preflight before PyPI Release (patch bump).
|
||||
# Triggers: Core Tests success on main (workflow_run) or nightly cron at 00:00 UTC.
|
||||
# Dedupe: max one successful patch release per UTC day (see release-gate.js).
|
||||
# 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).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '0 0 */3 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
|
||||
@@ -134,7 +134,9 @@ jobs:
|
||||
'pypi-auto' || 'pypi'
|
||||
}}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
|
||||
UV_PUBLISH_USERNAME: __token__
|
||||
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
|
||||
@@ -83,6 +83,7 @@ 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>
|
||||
@@ -568,6 +569,7 @@ 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>
|
||||
@@ -731,7 +733,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, RunOutcome, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -799,12 +801,16 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
|
||||
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.155" \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.155" \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.155" \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
|
||||
agent.launch(port=8080, protocol="mcp", path="/mcp")
|
||||
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")
|
||||
@@ -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={**rerank_config, "sources": sample_documents},
|
||||
knowledge={"sources": sample_documents, "rerank": True},
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ agents_system = AgentTeam(
|
||||
print("Starting graph memory demonstration...")
|
||||
result = agents_system.start()
|
||||
|
||||
print(f"\nGraph Memory Result: {result[:200]}...")
|
||||
result_text = str(result) if result is not None else ""
|
||||
print(f"\nGraph Memory Result: {result_text[:200]}...")
|
||||
print("\n✅ Graph memory integration complete!")
|
||||
print("Agent built knowledge graph and performed relationship-aware queries.")
|
||||
@@ -14,18 +14,18 @@ def test_imports():
|
||||
"""Test that all required modules can be imported."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
print("✅ Agent imported successfully")
|
||||
print("[OK] Agent imported successfully")
|
||||
|
||||
from praisonaiagents.tools import execute_command
|
||||
print("✅ execute_command tool imported successfully")
|
||||
print("[OK] execute_command tool imported successfully")
|
||||
|
||||
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
|
||||
print("✅ Approval system imported successfully")
|
||||
print("[OK] Approval system imported successfully")
|
||||
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Import failed: {e}")
|
||||
print(f"[FAIL] Import failed: {ascii(e)}")
|
||||
return False
|
||||
|
||||
def test_agent_creation():
|
||||
@@ -40,11 +40,11 @@ def test_agent_creation():
|
||||
)
|
||||
|
||||
assert agent.name == 'test-agent'
|
||||
print("✅ Agent creation successful")
|
||||
print("[OK] Agent creation successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent creation failed: {e}")
|
||||
print(f"[FAIL] Agent creation failed: {ascii(e)}")
|
||||
return False
|
||||
|
||||
def test_approval_system():
|
||||
@@ -55,11 +55,11 @@ def test_approval_system():
|
||||
registry = get_approval_registry()
|
||||
registry.set_backend(AutoApproveBackend())
|
||||
|
||||
print("✅ Approval system configuration successful")
|
||||
print("[OK] Approval system configuration successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Approval system test failed: {e}")
|
||||
print(f"[FAIL] Approval system test failed: {ascii(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("✅ External agent import successful")
|
||||
print("[OK] External agent import successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ External agent import failed: {e}")
|
||||
print(f"[FAIL] External agent import failed: {ascii(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("✅ Multi-agent example import successful")
|
||||
print("[OK] Multi-agent example import successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Multi-agent example import failed: {e}")
|
||||
print(f"[FAIL] Multi-agent example import failed: {ascii(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("✅ Wrapper agent import successful (CLI-based approach)")
|
||||
print("[OK] Wrapper agent import successful (CLI-based approach)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Wrapper agent import failed: {e}")
|
||||
print(f"[FAIL] Wrapper agent import failed: {ascii(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🧪 Testing {test_name}...")
|
||||
print(f"\n[TEST] {test_name}...")
|
||||
try:
|
||||
if test_func():
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f"❌ Test {test_name} crashed: {e}")
|
||||
print(f"[FAIL] Test {test_name} crashed: {ascii(e)}")
|
||||
|
||||
print("\n" + "=" * 55)
|
||||
print(f"Results: {passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 ALL BASIC TESTS PASSED!")
|
||||
print("[OK] ALL BASIC TESTS PASSED!")
|
||||
print("The Terminal-Bench integration components are properly structured.")
|
||||
else:
|
||||
print("❌ Some tests failed - check output above")
|
||||
print("[FAIL] Some tests failed - check output above")
|
||||
sys.exit(1)
|
||||
+4
-2
@@ -30,9 +30,11 @@ def main():
|
||||
else:
|
||||
print(f"Found {len(stories)} top stories:")
|
||||
for story in stories:
|
||||
print(f" - {story.get('title', 'N/A')[:50]}...")
|
||||
title = story.get("title") or "N/A"
|
||||
url = story.get("url") or "N/A"
|
||||
print(f" - {title[:50]}...")
|
||||
print(f" Score: {story.get('score', 'N/A')} | Comments: {story.get('descendants', 'N/A')}")
|
||||
print(f" URL: {story.get('url', 'N/A')[:60]}...")
|
||||
print(f" URL: {url[:60]}...")
|
||||
print()
|
||||
|
||||
print("✅ Hacker News tool working correctly!")
|
||||
|
||||
@@ -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 | 14657 |
|
||||
| ✅ Agent | 21 | 46 | 14663 |
|
||||
| ✅ 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 | 29 | 11795 |
|
||||
| ✅ Bots | 7 | 31 | 12353 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 119 | 34087 |
|
||||
| ✅ CLI | 5 | 119 | 34537 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 13 | 4031 |
|
||||
| ✅ Conditions | 1 | 3 | 1179 |
|
||||
| ✅ Configuration | 3 | 7 | 2772 |
|
||||
| ✅ Context Management | 16 | 34 | 13204 |
|
||||
| ✅ Configuration | 3 | 7 | 2773 |
|
||||
| ✅ Context Management | 16 | 34 | 13206 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1528 |
|
||||
| ✅ Execution | 3 | 4 | 1560 |
|
||||
| ✅ Failover | 2 | 1 | 400 |
|
||||
| ✅ Files | 2 | 6 | 1964 |
|
||||
| ✅ Files | 2 | 6 | 2066 |
|
||||
| ✅ Flow | 1 | 3 | 777 |
|
||||
| ✅ Gateway | 7 | 56 | 20195 |
|
||||
| ✅ Gateway | 7 | 59 | 21584 |
|
||||
| ✅ 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 | 4 | 1202 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ 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 | 1913 |
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ 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 | 2700 |
|
||||
| ✅ Sandbox | 5 | 6 | 2766 |
|
||||
| ✅ Security | 1 | 3 | 2970 |
|
||||
| ✅ Sessions | 4 | 13 | 5286 |
|
||||
| ✅ Skills | 6 | 13 | 4946 |
|
||||
| ✅ Tasks | 2 | 6 | 2743 |
|
||||
| ✅ Sessions | 4 | 13 | 5302 |
|
||||
| ✅ Skills | 6 | 13 | 4998 |
|
||||
| ✅ Tasks | 2 | 6 | 2809 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Templates | 1 | 8 | 1657 |
|
||||
| ✅ Tools | 12 | 133 | 37934 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 133 | 38149 |
|
||||
| ✅ 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 (40 docs, 6184 lines)
|
||||
- ℹ️ Database (41 docs, 6478 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -258,6 +258,7 @@ _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'),
|
||||
|
||||
@@ -838,6 +839,7 @@ __all__ = [
|
||||
|
||||
# Core classes - the essentials
|
||||
'Agent',
|
||||
'RunOutcome',
|
||||
'RetryBackoffConfig',
|
||||
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
|
||||
'AgentManager', # Silent alias for AgentTeam
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -196,6 +195,9 @@ 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,6 +37,15 @@ def __getattr__(name):
|
||||
from .interrupt import InterruptController
|
||||
_lazy_cache[name] = InterruptController
|
||||
return InterruptController
|
||||
if name in ('RunOutcome', 'TerminalReason'):
|
||||
from . import run_outcome as _run_outcome_module
|
||||
value = getattr(_run_outcome_module, name)
|
||||
_lazy_cache[name] = value
|
||||
return value
|
||||
if name == 'prompt_prefix_signature':
|
||||
from .prompt_cache import prompt_prefix_signature
|
||||
_lazy_cache[name] = prompt_prefix_signature
|
||||
return prompt_prefix_signature
|
||||
|
||||
# Specialized agents - lazy loaded (import rich)
|
||||
if name == 'ImageAgent':
|
||||
@@ -215,6 +224,9 @@ __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
|
||||
from .async_safety import AsyncSafeState, DualLock
|
||||
# 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,7 +2230,10 @@ 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
|
||||
@@ -3198,7 +3201,44 @@ 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,7 +7,6 @@ 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,7 +34,6 @@ 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,8 +2462,7 @@ 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).
|
||||
if not getattr(self, "_in_skill_review", False):
|
||||
self._turn_tools_used = []
|
||||
self._reset_turn_tools()
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
@@ -3064,8 +3063,7 @@ 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).
|
||||
if not getattr(self, "_in_skill_review", False):
|
||||
self._turn_tools_used = []
|
||||
self._reset_turn_tools()
|
||||
# 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)():
|
||||
@@ -3733,8 +3731,20 @@ 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
|
||||
tool = next((t for t in tools if t.__name__ == function_name), None)
|
||||
# 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,
|
||||
)
|
||||
if not tool:
|
||||
_get_display_functions()['display_error'](f"Tool {function_name} not found")
|
||||
continue
|
||||
|
||||
@@ -12,7 +12,6 @@ Usage::
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -34,7 +34,6 @@ Example:
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
@@ -125,16 +125,30 @@ class ExecutionMixin:
|
||||
|
||||
Args:
|
||||
prompt: The input prompt to process
|
||||
**kwargs: Additional arguments passed to achat()
|
||||
**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.
|
||||
|
||||
Returns:
|
||||
The agent's response as a string, or AutonomyResult if autonomy enabled
|
||||
The agent's response as a string, or AutonomyResult if autonomy
|
||||
enabled, or a ``RunOutcome`` when ``return_outcome=True``.
|
||||
|
||||
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
|
||||
@@ -196,6 +210,57 @@ 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.
|
||||
|
||||
@@ -208,9 +273,14 @@ 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
|
||||
The agent's response as a string, or a ``RunOutcome`` when
|
||||
``return_outcome=True``.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -227,8 +297,14 @@ 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
|
||||
@@ -242,17 +318,34 @@ class ExecutionMixin:
|
||||
|
||||
# Load history context
|
||||
self._load_history_context()
|
||||
|
||||
# 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 _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)
|
||||
|
||||
def _delegate_to_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Delegate execution to external managed backend (e.g., ManagedAgentIntegration).
|
||||
@@ -1146,13 +1239,9 @@ 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.
|
||||
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)
|
||||
# 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)
|
||||
|
||||
# Enforce BEFORE_TOOL/AFTER_TOOL security hooks for every async caller,
|
||||
# mirroring the sync execute_tool path in tool_execution.py. Without
|
||||
@@ -1369,11 +1458,22 @@ 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
|
||||
# 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.
|
||||
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 (callable(tool) and getattr(tool, '__name__', '') == function_name):
|
||||
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):
|
||||
func = tool
|
||||
break
|
||||
|
||||
@@ -1382,15 +1482,18 @@ Write the complete compiled report:"""
|
||||
return {"error": f"Function {function_name} not found in tools"}
|
||||
|
||||
try:
|
||||
if inspect.iscoroutinefunction(func):
|
||||
# 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):
|
||||
logging.debug(f"Executing async function: {function_name}")
|
||||
result = await func(**arguments)
|
||||
result = await call_target(**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: func(**arguments))
|
||||
None, copy_context_to_callable(lambda: call_target(**arguments))
|
||||
)
|
||||
|
||||
# Ensure result is JSON serializable
|
||||
@@ -1474,157 +1577,166 @@ Write the complete compiled report:"""
|
||||
|
||||
should_start = False
|
||||
with _server_lock:
|
||||
# Initialize port-specific collections if needed
|
||||
# Initialize port-specific collections if needed (once per port)
|
||||
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"
|
||||
# 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)}"}
|
||||
)
|
||||
|
||||
# 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())
|
||||
}
|
||||
# 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 healthcheck endpoint
|
||||
@_shared_apps[port].get("/health")
|
||||
async def healthcheck():
|
||||
return {
|
||||
"status": "ok",
|
||||
"endpoints": list(_registered_agents[port].keys())
|
||||
}
|
||||
print(f"🚀 Agent '{self.name}' available at http://{host}:{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)}"}
|
||||
)
|
||||
|
||||
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
|
||||
# Check and mark server as started atomically to prevent race conditions
|
||||
should_start = not _server_started.get(port, False)
|
||||
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)}")
|
||||
_server_started[port] = True
|
||||
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
# 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")
|
||||
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:
|
||||
# If something goes wrong with detection, block anyway to be safe
|
||||
logging.error(f"Error in launch detection: {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("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
|
||||
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")
|
||||
return None
|
||||
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
|
||||
|
||||
def _launch_mcp_server(self, path: str, port: int, host: str, debug: bool):
|
||||
"""
|
||||
@@ -1671,7 +1783,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.create_app())]
|
||||
routes=[Mount(base_path, mcp.sse_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,33 +163,50 @@ from ..errors import (
|
||||
HandoffValidationError
|
||||
)
|
||||
|
||||
# Thread-local storage for tracking handoff chains
|
||||
_handoff_context = threading.local()
|
||||
# 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
|
||||
)
|
||||
|
||||
def _get_handoff_chain() -> List[str]:
|
||||
"""Get current handoff chain from thread-local storage."""
|
||||
if not hasattr(_handoff_context, 'chain'):
|
||||
_handoff_context.chain = []
|
||||
return _handoff_context.chain
|
||||
"""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
|
||||
|
||||
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."""
|
||||
chain = _get_handoff_chain()
|
||||
"""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())
|
||||
chain.append(agent_name)
|
||||
_handoff_chain_var.set(chain)
|
||||
|
||||
def _pop_handoff() -> Optional[str]:
|
||||
"""Pop agent from handoff chain."""
|
||||
chain = _get_handoff_chain()
|
||||
return chain.pop() if chain else None
|
||||
chain = list(_get_handoff_chain())
|
||||
popped = chain.pop() if chain else None
|
||||
_handoff_chain_var.set(chain)
|
||||
return popped
|
||||
|
||||
def _clear_handoff_chain() -> None:
|
||||
"""Clear the handoff chain."""
|
||||
if hasattr(_handoff_context, 'chain'):
|
||||
_handoff_context.chain = []
|
||||
_handoff_chain_var.set([])
|
||||
|
||||
@dataclass
|
||||
class HandoffInputData:
|
||||
|
||||
@@ -13,7 +13,6 @@ Usage::
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -22,7 +22,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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,7 +6,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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,7 +5,6 @@ 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,6 +471,46 @@ 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.
|
||||
|
||||
@@ -487,13 +527,9 @@ 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.
|
||||
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)
|
||||
# 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)
|
||||
|
||||
# Emit tool call start event (zero overhead when not set)
|
||||
_trace_emitter = get_context_emitter()
|
||||
@@ -1132,8 +1168,9 @@ 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 = list(getattr(self, "_turn_tools_used", []) or [])
|
||||
self._turn_tools_used = []
|
||||
tools_used = self._drain_turn_tools()
|
||||
else:
|
||||
self._drain_turn_tools()
|
||||
# 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):
|
||||
@@ -1189,8 +1226,9 @@ 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 = list(getattr(self, "_turn_tools_used", []) or [])
|
||||
self._turn_tools_used = []
|
||||
tools_used = self._drain_turn_tools()
|
||||
else:
|
||||
self._drain_turn_tools()
|
||||
# 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):
|
||||
@@ -1395,7 +1433,9 @@ 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)
|
||||
registry_required = approval_registry.is_required(
|
||||
tool_name, getattr(self, 'name', None)
|
||||
)
|
||||
# Check if tool needs approval based on multiple criteria
|
||||
needs_approval = (
|
||||
approve_all
|
||||
@@ -1409,7 +1449,7 @@ class ToolExecutionMixin:
|
||||
tool_name=tool_name,
|
||||
arguments=tool_args,
|
||||
risk_level=(
|
||||
approval_registry.get_risk_level(tool_name)
|
||||
approval_registry.get_risk_level(tool_name, getattr(self, 'name', None))
|
||||
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
|
||||
),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
@@ -1474,21 +1514,19 @@ 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, 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
|
||||
# ``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.
|
||||
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:
|
||||
@@ -1602,7 +1640,9 @@ 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):
|
||||
if get_approval_registry().is_required(
|
||||
function_name, getattr(self, 'name', None)
|
||||
):
|
||||
return True
|
||||
from ..tools import get_registry as get_tool_registry
|
||||
if get_tool_registry().get_trust_level(function_name) == "external":
|
||||
|
||||
@@ -330,17 +330,26 @@ async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, st
|
||||
)
|
||||
|
||||
|
||||
def _build_execution_context(agents_instance, task_id):
|
||||
def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
"""
|
||||
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
|
||||
if not task.memory:
|
||||
# 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:
|
||||
task.memory = task.initialize_memory()
|
||||
|
||||
executor_agent = task.agent
|
||||
@@ -842,6 +851,11 @@ 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')
|
||||
@@ -960,6 +974,63 @@ 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
|
||||
@@ -1054,8 +1125,17 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Build execution context using DRY helper
|
||||
context = _build_execution_context(self, task_id)
|
||||
# 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)
|
||||
|
||||
# Execute with agent using DRY helper
|
||||
agent_output = await _execute_with_agent_async(
|
||||
|
||||
@@ -17,7 +17,6 @@ Usage:
|
||||
result = auto_rag.chat("Hello!") # Skips retrieval
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,7 +11,6 @@ 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,7 +20,6 @@ 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
|
||||
@@ -91,10 +90,19 @@ 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)
|
||||
# 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).
|
||||
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] = {}
|
||||
|
||||
@@ -153,18 +161,51 @@ class ApprovalRegistry:
|
||||
|
||||
# ── Tool requirement management ──────────────────────────────────────
|
||||
|
||||
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
|
||||
def add_requirement(
|
||||
self,
|
||||
tool_name: str,
|
||||
risk_level: str = "high",
|
||||
agent_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Mark *tool_name* as requiring approval.
|
||||
|
||||
def remove_requirement(self, tool_name: str) -> None:
|
||||
self._required_tools.discard(tool_name)
|
||||
self._risk_levels.pop(tool_name, None)
|
||||
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 is_required(self, tool_name: str) -> bool:
|
||||
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
|
||||
return tool_name in self._required_tools
|
||||
|
||||
def get_risk_level(self, tool_name: str) -> Optional[str]:
|
||||
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
|
||||
return self._risk_levels.get(tool_name)
|
||||
|
||||
# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────
|
||||
@@ -346,10 +387,17 @@ class ApprovalRegistry:
|
||||
agent_name: Optional[str],
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
) -> ApprovalDecision:
|
||||
"""Synchronous approval — used by ``Agent._execute_tool_impl``."""
|
||||
# Fast-path: not required
|
||||
if not self.is_required(tool_name):
|
||||
"""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):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
# Already approved in this context
|
||||
@@ -381,7 +429,7 @@ class ApprovalRegistry:
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
risk_level=self._risk_levels.get(tool_name, "medium"),
|
||||
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
@@ -406,10 +454,15 @@ class ApprovalRegistry:
|
||||
agent_name: Optional[str],
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
) -> ApprovalDecision:
|
||||
"""Asynchronous approval — used by async tool execution path."""
|
||||
# Fast-path: not required
|
||||
if not self.is_required(tool_name):
|
||||
"""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):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
if self.is_already_approved(tool_name, arguments):
|
||||
@@ -437,7 +490,7 @@ class ApprovalRegistry:
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
risk_level=self._risk_levels.get(tool_name, "medium"),
|
||||
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,7 +6,6 @@ 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,6 +33,9 @@ from .protocols import (
|
||||
WebhookVerifierProtocol,
|
||||
CallbackPayloadStoreProtocol,
|
||||
InMemoryCallbackPayloadStore,
|
||||
GatewayRuntimeSeams,
|
||||
SupportsGatewayRuntime,
|
||||
GatewayAdapterContractError,
|
||||
)
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
@@ -122,6 +125,9 @@ __all__ = [
|
||||
"WebhookVerifierProtocol",
|
||||
"CallbackPayloadStoreProtocol",
|
||||
"InMemoryCallbackPayloadStore",
|
||||
"GatewayRuntimeSeams",
|
||||
"SupportsGatewayRuntime",
|
||||
"GatewayAdapterContractError",
|
||||
"BasePlatformAdapter",
|
||||
"SendResult",
|
||||
"InteractiveContext",
|
||||
|
||||
@@ -302,6 +302,79 @@ 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,7 +5,6 @@ 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,7 +7,6 @@ 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,7 +160,8 @@ class ContextCompactor:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return max(1, len(text) // 4)
|
||||
from ..context.tokens import estimate_tokens_heuristic
|
||||
return estimate_tokens_heuristic(text)
|
||||
|
||||
def count_message_tokens(self, message: Dict[str, Any]) -> int:
|
||||
"""Count tokens in a message, including tool_calls payloads."""
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,7 +950,16 @@ 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),
|
||||
@@ -958,13 +967,17 @@ 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", "docker"),
|
||||
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
|
||||
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,42 +50,6 @@ 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.
|
||||
@@ -136,21 +100,6 @@ 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.
|
||||
@@ -246,28 +195,6 @@ 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,7 +6,6 @@ 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,7 +12,6 @@ Design principles:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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,7 +7,6 @@ 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,7 +25,6 @@ Example usage:
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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,7 +15,6 @@ 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,7 +12,6 @@ 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
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
"""
|
||||
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,7 +11,6 @@ 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,7 +11,6 @@ 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,7 +11,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
"""
|
||||
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,7 +15,6 @@ 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,7 +9,6 @@ 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,7 +18,6 @@ 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,7 +10,6 @@ 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,7 +5,6 @@ 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,7 +4,6 @@ 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,7 +5,6 @@ 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,7 +7,6 @@ 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,7 +5,6 @@ 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,7 +9,6 @@ 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,7 +15,6 @@ 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,7 +18,6 @@ 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,7 +7,6 @@ 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,7 +5,6 @@ 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,7 +4,6 @@ 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,7 +18,6 @@ Example:
|
||||
... # Split into chunks
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,6 +70,10 @@ 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,
|
||||
@@ -277,6 +281,10 @@ __all__ = [
|
||||
"GatewayConcurrencyPolicyProtocol",
|
||||
"GatewayConcurrencyPolicy",
|
||||
"ConcurrencyLimitPolicy",
|
||||
# Gateway resource-pressure admission (Issue #3445)
|
||||
"ResourceSample",
|
||||
"ResourcePressurePolicyProtocol",
|
||||
"MemoryPressurePolicy",
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
"RateLimitDecision",
|
||||
"RateLimitPolicyProtocol",
|
||||
|
||||
@@ -63,6 +63,9 @@ 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
|
||||
@@ -73,6 +76,7 @@ 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)
|
||||
@@ -90,6 +94,10 @@ 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."""
|
||||
@@ -98,6 +106,7 @@ 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,
|
||||
@@ -685,6 +694,7 @@ 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,6 +2928,135 @@ 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,7 +5,6 @@ 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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""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,7 +4,6 @@ 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,7 +9,6 @@ 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,6 +36,12 @@ 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,7 +7,6 @@ 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,7 +8,6 @@ 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,7 +10,6 @@ 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,7 +10,6 @@ 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,7 +10,6 @@ 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,7 +9,6 @@ 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,7 +10,6 @@ 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,7 +9,6 @@ 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,7 +10,6 @@ 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,30 +714,6 @@ 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,7 +6,6 @@ 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,7 +23,6 @@ Usage:
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,7 +282,20 @@ 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):
|
||||
"""
|
||||
@@ -834,6 +847,15 @@ 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
Reference in new issue
Block a user