mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 17:31:52 -10:00
Compare commits
288
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0891b29e9e | ||
|
|
4a4b7a3208 | ||
|
|
d8a8a78691 | ||
|
|
120d299d85 | ||
|
|
2738e31d1f | ||
|
|
e5928449f7 | ||
|
|
2c84756b1f | ||
|
|
68cc9427ff | ||
|
|
37f979979c | ||
|
|
7d4d36a422 | ||
|
|
8c0fbcf5ef | ||
|
|
8aa1f1f785 | ||
|
|
75400e3b9c | ||
|
|
dfbb8d78ec | ||
|
|
750d9e7a62 | ||
|
|
392a708db9 | ||
|
|
a8394cfa15 | ||
|
|
bb43815a0e | ||
|
|
474acd457f | ||
|
|
0f6af9c754 | ||
|
|
004dcfef45 | ||
|
|
9a06d5741e | ||
|
|
e167e4875d | ||
|
|
641155d4c4 | ||
|
|
bbd69ab480 | ||
|
|
7bdffd804f | ||
|
|
d0d87a7408 | ||
|
|
7653e0342e | ||
|
|
6b04a1aa50 | ||
|
|
f1baa1e632 | ||
|
|
dfb0b24a0c | ||
|
|
754300eacc | ||
|
|
23d63aeb66 | ||
|
|
9d6d42b34a | ||
|
|
b6c9722333 | ||
|
|
1d3309f4b2 | ||
|
|
723ba6c38f | ||
|
|
324defa545 | ||
|
|
14aef1eed4 | ||
|
|
3d4b747000 | ||
|
|
d30ed3109e | ||
|
|
4b1103c683 | ||
|
|
00156b17fa | ||
|
|
1b37d3a44f | ||
|
|
6ded0dbc65 | ||
|
|
6b3a999687 | ||
|
|
4ce799cf01 | ||
|
|
d6c367f321 | ||
|
|
c373f1430e | ||
|
|
dc02422cc3 | ||
|
|
6b7d49d001 | ||
|
|
83fd376892 | ||
|
|
26d166d379 | ||
|
|
ac067f71d9 | ||
|
|
7bb5911f4d | ||
|
|
4faf056353 | ||
|
|
69b293ef30 | ||
|
|
4a82c9cc0a | ||
|
|
77381f11f8 | ||
|
|
0634923332 | ||
|
|
d2ebc372b3 | ||
|
|
32b4303f08 | ||
|
|
dcff27aaa0 | ||
|
|
914a1a24a9 | ||
|
|
39f2c79d97 | ||
|
|
b9b3594728 | ||
|
|
1138b89202 | ||
|
|
6821fb32c3 | ||
|
|
6ee8fdff49 | ||
|
|
e6bbd7af23 | ||
|
|
f8fe440789 | ||
|
|
1ae50d5686 | ||
|
|
245a2d11a0 | ||
|
|
26e8f5ba4f | ||
|
|
769571a85f | ||
|
|
fe66a855a5 | ||
|
|
51450ffd1e | ||
|
|
70c0d78b0f | ||
|
|
d57b0adb16 | ||
|
|
5c07f7cb3a | ||
|
|
447b5da657 | ||
|
|
9b6563aafb | ||
|
|
abdd63fda4 | ||
|
|
31b04daf70 | ||
|
|
00d603ff87 | ||
|
|
13097b5e87 | ||
|
|
bac89d2317 | ||
|
|
1123daf17c | ||
|
|
04edeac150 | ||
|
|
2284a84188 | ||
|
|
38897a2f74 | ||
|
|
37be902b3f | ||
|
|
96a885a0b8 | ||
|
|
9631140a28 | ||
|
|
d23c0513f2 | ||
|
|
af6fc7de53 | ||
|
|
ba1fadee93 | ||
|
|
ba4f8b574b | ||
|
|
d168fcbfe8 | ||
|
|
19bd60a45d | ||
|
|
06c947a85c | ||
|
|
83d53f3d0d | ||
|
|
714fb4889a | ||
|
|
b415af8b27 | ||
|
|
2084f1c3ed | ||
|
|
0bcff8348c | ||
|
|
5b10dd649a | ||
|
|
918c44e7f1 | ||
|
|
06d82948e9 | ||
|
|
55452616a3 | ||
|
|
3daa6a9e68 | ||
|
|
936f39ede0 | ||
|
|
cf66a74122 | ||
|
|
b07e0322dc | ||
|
|
a10c292f0d | ||
|
|
eee68f89f8 | ||
|
|
40ab4cfbb2 | ||
|
|
e84a268b62 | ||
|
|
bd21c6b9f9 | ||
|
|
4a4bb4b6a7 | ||
|
|
d25e852c2d | ||
|
|
d026de9a3d | ||
|
|
38d33f5b59 | ||
|
|
4d4d456c56 | ||
|
|
df91bace8e | ||
|
|
06037aa18f | ||
|
|
78989ce4fd | ||
|
|
c94d405e84 | ||
|
|
115b3fb3fc | ||
|
|
588d432e62 | ||
|
|
b9836f3e08 | ||
|
|
61e53754d4 | ||
|
|
bbcdf65b5a | ||
|
|
dd03c817b7 | ||
|
|
3ff99487d6 | ||
|
|
c47f572655 | ||
|
|
9c970df301 | ||
|
|
6f1d55c912 | ||
|
|
3967dd164f | ||
|
|
4737f868a8 | ||
|
|
ed3313752d | ||
|
|
8d380b9307 | ||
|
|
18482fddf8 | ||
|
|
e86d3620ae | ||
|
|
79b67964b9 | ||
|
|
cf97450239 | ||
|
|
9147e55e1a | ||
|
|
5eb8ec20f6 | ||
|
|
b355954d41 | ||
|
|
4b1b17b963 | ||
|
|
ef81500433 | ||
|
|
cb401fed52 | ||
|
|
e1642eebda | ||
|
|
6baed2f2fc | ||
|
|
2bc3ffd108 | ||
|
|
a17826b131 | ||
|
|
ac4e0590af | ||
|
|
a4b6d00248 | ||
|
|
d8e52bb298 | ||
|
|
f17de6e429 | ||
|
|
220d0d5717 | ||
|
|
b4099b2840 | ||
|
|
819a1653ca | ||
|
|
fa90dea317 | ||
|
|
b87d7b815d | ||
|
|
362e6acf9d | ||
|
|
d140e331e6 | ||
|
|
36822ef18c | ||
|
|
85fb28e613 | ||
|
|
8e308d4bb3 | ||
|
|
6df0437bd7 | ||
|
|
af1fab488e | ||
|
|
0805e027f7 | ||
|
|
c9548dc738 | ||
|
|
0b0b341dba | ||
|
|
9463b1f641 | ||
|
|
9df266a2bd | ||
|
|
117b0706cc | ||
|
|
05e379f375 | ||
|
|
799972fc0d | ||
|
|
a6094f56e8 | ||
|
|
dabc9f9867 | ||
|
|
c040cedabb | ||
|
|
2856f91b4f | ||
|
|
8aa510a3f1 | ||
|
|
6a99b73fc5 | ||
|
|
90aaa84742 | ||
|
|
70152e1a8f | ||
|
|
f61325f673 | ||
|
|
b00b2761b1 | ||
|
|
706e80cb50 | ||
|
|
64520be22f | ||
|
|
d34176c62d | ||
|
|
947fe66c0f | ||
|
|
11bbf9ab75 | ||
|
|
5c74d64c49 | ||
|
|
47d3e42bc6 | ||
|
|
f5184c3405 | ||
|
|
07dc91c938 | ||
|
|
2d05e6f7c7 | ||
|
|
1c3055b55a | ||
|
|
92ce12a03d | ||
|
|
74fd40ff53 | ||
|
|
99b4ddaf9a | ||
|
|
94094a8033 | ||
|
|
86f7c5359e | ||
|
|
4937912577 | ||
|
|
de7a607374 | ||
|
|
0f8cb4d72c | ||
|
|
13188fc3f9 | ||
|
|
3443f73df2 | ||
|
|
05dd2a3928 | ||
|
|
0ca192990c | ||
|
|
0251157632 | ||
|
|
7e5ce3a10b | ||
|
|
c45083672f | ||
|
|
38cebe1dda | ||
|
|
52ed8a69d0 | ||
|
|
c7051ed67f | ||
|
|
b9acfc4c89 | ||
|
|
6bec7c60f0 | ||
|
|
9049e90eb2 | ||
|
|
d52224764d | ||
|
|
a8639c353e | ||
|
|
99a4dee308 | ||
|
|
bd33c84b25 | ||
|
|
e29800964d | ||
|
|
c18ff8cde0 | ||
|
|
bf866fcb0a | ||
|
|
6e13e86f53 | ||
|
|
d95fc926b7 | ||
|
|
0085ea505e | ||
|
|
19297c0756 | ||
|
|
8fc47bd6e4 | ||
|
|
f675b8dc0f | ||
|
|
e11d456168 | ||
|
|
36c727ff3c | ||
|
|
7de1755bb6 | ||
|
|
e5583c131d | ||
|
|
53348aec19 | ||
|
|
0876a25ebb | ||
|
|
2ae200e0fb | ||
|
|
38c266314d | ||
|
|
2f1a401e2f | ||
|
|
a54dff4d12 | ||
|
|
a92e97ef57 | ||
|
|
d7fef28f6e | ||
|
|
691349f713 | ||
|
|
d135702fa0 | ||
|
|
2c4c3e0443 | ||
|
|
5d82403a2d | ||
|
|
00bef43968 | ||
|
|
6799f2a4c1 | ||
|
|
e601a2ffa1 | ||
|
|
89d0a8ba33 | ||
|
|
202050b644 | ||
|
|
49d9b60fbd | ||
|
|
0d3263622f | ||
|
|
ad55d528b6 | ||
|
|
7ad776b2e8 | ||
|
|
202d95dc32 | ||
|
|
c23642c4e5 | ||
|
|
1e4a1c77f4 | ||
|
|
7ae9a70ba2 | ||
|
|
8e6b50c11a | ||
|
|
b8bfaa53b9 | ||
|
|
f015ac7753 | ||
|
|
1277a7a08b | ||
|
|
9838430046 | ||
|
|
a5add02561 | ||
|
|
c25c1cc18e | ||
|
|
8f35439583 | ||
|
|
21ed0a4379 | ||
|
|
75e17ae7c2 | ||
|
|
3c3188c8c1 | ||
|
|
d76b56ec6b | ||
|
|
f5d3e26de9 | ||
|
|
ec52231f62 | ||
|
|
81dfb6a555 | ||
|
|
bc52cd67fd | ||
|
|
d4b563987f | ||
|
|
4c68e61438 | ||
|
|
f7d3b89610 | ||
|
|
c13b9a7489 | ||
|
|
1928db8554 | ||
|
|
a331eb4f8c | ||
|
|
e64c24574c | ||
|
|
cfc221f25c |
No files matched your search
@@ -183,7 +183,7 @@ jobs:
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. ' + reviewSummary + '.\n\n' +
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
label_trigger: "claude"
|
||||
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || '' }}
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
@@ -212,6 +212,7 @@ jobs:
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
@@ -230,9 +231,14 @@ jobs:
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
|
||||
- Create a fix branch and implement a minimal, focused fix
|
||||
- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
- Implement a minimal, focused fix
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"
|
||||
- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
CRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
Do NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.
|
||||
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
|
||||
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
+2
-1
@@ -97,4 +97,5 @@ Cargo.lock
|
||||
|
||||
src/praisonai/build*.egg-info/
|
||||
src/praisonai/PraisonAI.egg-info/
|
||||
jobs/*
|
||||
jobs/*
|
||||
*.egg-info
|
||||
@@ -23,7 +23,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
|
||||
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous, self-improving agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://praison.ai/install.sh | bash
|
||||
```
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
|
||||
@@ -172,6 +172,7 @@ Methods:
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
|
||||
@@ -179,6 +180,7 @@ Methods:
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get</a>(name: str) -> Optional[Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_all</a>() -> Dict[str, Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_base_tools</a>() -> List[BaseTool]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools</a>() -> List[str]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">register</a>(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None</code>
|
||||
@@ -206,10 +208,12 @@ Methods:
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory_async</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">set_status</a>(new_status: str) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List) -> Parallel</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List, max_workers: Optional[int] = None, on_failure: str = 'partial_ok') -> Parallel</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">route</a>(routes: Dict[str, List], default: Optional[List] = None) -> Route</code>
|
||||
|
||||
@@ -466,14 +470,23 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
@@ -700,6 +713,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">close</a>() -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">context_manager</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">current_plan</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">default_completion_checker</a>(task, agent_output)</code>
|
||||
@@ -741,7 +755,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -771,6 +785,7 @@ Methods:
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
@@ -811,6 +826,7 @@ Methods:
|
||||
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai dashboard unified <a href="./src/praisonai/praisonai/cli/commands/dashboard.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug interactive <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
@@ -850,19 +866,27 @@ Methods:
|
||||
* <code title="cli">praisonai eval judge <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval list-judges <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai eval performance <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples find <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples info <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples list <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples report <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow export <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow flow-start <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow import <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow list <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow version <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway mint-link <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -871,6 +895,8 @@ Methods:
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge search <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai langextract render <a href="./src/praisonai/praisonai/cli/commands/langextract.py">--help</a></code>
|
||||
* <code title="cli">praisonai langextract view <a href="./src/praisonai/praisonai/cli/commands/langextract.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse config <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse connect <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse init <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
@@ -889,6 +915,7 @@ Methods:
|
||||
* <code title="cli">praisonai lsp start <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed delete <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed get <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
@@ -916,10 +943,22 @@ Methods:
|
||||
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n list <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n preview <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n pull <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n push <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n test <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai obs obs-fallback <a href="./src/praisonai/praisonai/cli/commands/obs.py">--help</a></code>
|
||||
* <code title="cli">praisonai onboard onboard-callback <a href="./src/praisonai/praisonai/cli/commands/onboard.py">--help</a></code>
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing approve <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing clear <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing list <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing revoke <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
@@ -1016,6 +1055,10 @@ Methods:
|
||||
* <code title="cli">praisonai session list <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup config <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup reset <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup setup-callback <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai setup wizard <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills check <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
|
||||
@@ -1055,7 +1098,13 @@ Methods:
|
||||
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train train-callback <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui agents <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui bot <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui realtime <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui ui <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai up logs <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
|
||||
* <code title="cli">praisonai up status <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
|
||||
* <code title="cli">praisonai up up-start <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
|
||||
* <code title="cli">praisonai version check <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
* <code title="cli">praisonai version show <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
* <code title="cli">praisonai version version-callback <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
|
||||
|
||||
@@ -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.2" \
|
||||
"praisonai>=4.6.35" \
|
||||
"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.2" \
|
||||
"praisonai>=4.6.35" \
|
||||
"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.2" \
|
||||
"praisonai>=4.6.35" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
"""
|
||||
Langfuse Integration Example
|
||||
Langfuse Integration Example (Updated for TraceSinkProtocol)
|
||||
|
||||
This example shows how to use Langfuse for LLM observability.
|
||||
This example shows how to use Langfuse for LLM observability with PraisonAI's native trace infrastructure.
|
||||
|
||||
Setup:
|
||||
1. Sign up at https://langfuse.com/
|
||||
2. Get your API keys from the project settings
|
||||
3. Set environment variables:
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
4. Install dependencies:
|
||||
pip install opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
pip install "praisonai[langfuse]"
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
|
||||
Usage:
|
||||
python langfuse_example.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Initialize Langfuse
|
||||
success = obs.init(
|
||||
provider="langfuse",
|
||||
project_name="praisonai-demo",
|
||||
from praisonai.observability import LangfuseSink
|
||||
from praisonaiagents.trace.protocol import (
|
||||
TraceEmitter, set_default_emitter
|
||||
)
|
||||
|
||||
if not success:
|
||||
print("Failed to initialize Langfuse. Check your API keys.")
|
||||
print("Required: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY")
|
||||
exit(1)
|
||||
# Initialize Langfuse observability
|
||||
sink = LangfuseSink()
|
||||
emitter = TraceEmitter(sink=sink, enabled=True)
|
||||
set_default_emitter(emitter)
|
||||
|
||||
print("Langfuse initialized successfully!")
|
||||
print(f"View traces at: https://cloud.langfuse.com/")
|
||||
|
||||
# Create agent
|
||||
# Create and run agent — all traces automatically captured
|
||||
agent = Agent(
|
||||
name="Coder",
|
||||
instructions="You are a helpful coding assistant.",
|
||||
model="gpt-4o-mini",
|
||||
llm="openai/gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Run with tracing
|
||||
with obs.trace("coding-session", user_id="developer-1"):
|
||||
response = agent.chat("Write a Python function to calculate fibonacci numbers")
|
||||
print(response)
|
||||
try:
|
||||
result = agent.start("Write a Python function to calculate fibonacci numbers")
|
||||
print(result)
|
||||
finally:
|
||||
# Ensure traces are flushed and resources cleaned up
|
||||
sink.flush()
|
||||
sink.close()
|
||||
|
||||
print("\nCheck Langfuse dashboard for traces!")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""All compute providers — comprehensive test across Local, Docker, E2B, and Modal.
|
||||
|
||||
This example mirrors the Anthropic app.py but uses the local provider with
|
||||
various compute backends instead of Anthropic's managed infrastructure.
|
||||
This example demonstrates local agent loops with various compute backends for tool sandboxing.
|
||||
Uses the new LocalAgent class to clearly indicate local execution with optional cloud compute.
|
||||
|
||||
Requires:
|
||||
- Docker running locally
|
||||
@@ -9,7 +9,8 @@ Requires:
|
||||
- modal CLI configured (modal token set)
|
||||
"""
|
||||
import asyncio
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
|
||||
async def test_provider(name, compute, extra_provision_kwargs=None):
|
||||
@@ -18,10 +19,9 @@ async def test_provider(name, compute, extra_provision_kwargs=None):
|
||||
print(f" PROVIDER: {name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="local",
|
||||
managed = LocalAgent(
|
||||
compute=compute,
|
||||
config=LocalManagedConfig(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful assistant. Be concise.",
|
||||
name=f"{name}Agent",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run every managed-runtime example and report a summary.
|
||||
|
||||
Executes the runtime_*.py files in this directory as subprocesses with a
|
||||
30s per-example timeout. Exits non-zero if any example fails.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXAMPLES = [
|
||||
# ── LLM providers (where the LLM reasoning happens) ──
|
||||
"runtime_anthropic.py", # full loop in Anthropic cloud
|
||||
"runtime_openai.py", # LLM in OpenAI cloud
|
||||
"runtime_gemini.py", # LLM in Google cloud
|
||||
"runtime_ollama.py", # LLM on local/self-hosted Ollama
|
||||
# ── Cloud/compute providers (where tool execution happens) ──
|
||||
"runtime_e2b.py", # tools sandboxed in E2B cloud VM
|
||||
"runtime_modal.py", # tools sandboxed in Modal serverless cloud
|
||||
"runtime_fly.py", # tools sandboxed on Fly.io Machines
|
||||
"runtime_daytona.py", # tools sandboxed in Daytona cloud workspace
|
||||
"runtime_docker.py", # tools sandboxed in local Docker container
|
||||
]
|
||||
|
||||
results = []
|
||||
for name in EXAMPLES:
|
||||
path = os.path.join(HERE, name)
|
||||
start = time.time()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, path],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=os.environ.copy(), # propagate API keys and PYTHONPATH
|
||||
)
|
||||
dur = time.time() - start
|
||||
ok = proc.returncode == 0
|
||||
results.append((name, ok, dur, proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""))
|
||||
print(f"[{'OK' if ok else 'FAIL'}] {name:30s} {dur:.2f}s")
|
||||
if not ok:
|
||||
print(proc.stdout)
|
||||
print(proc.stderr)
|
||||
|
||||
print("\nSummary:")
|
||||
for name, ok, dur, tail in results:
|
||||
print(f" {'✓' if ok else '✗'} {name:30s} {dur:5.2f}s {tail[:60]}")
|
||||
|
||||
sys.exit(0 if all(r[1] for r in results) else 1)
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Local provider — basic managed agent with gpt-4o-mini.
|
||||
"""Local agent — basic local execution with gpt-4o-mini.
|
||||
|
||||
No external infrastructure needed. Runs the agent loop locally.
|
||||
Uses the new canonical LocalAgent class for clarity.
|
||||
"""
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local managed agent (auto-detects local when no ANTHROPIC_API_KEY)
|
||||
managed = ManagedAgent(
|
||||
provider="local",
|
||||
config=LocalManagedConfig(
|
||||
# Create a local agent (runs locally, no managed runtime)
|
||||
managed = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful assistant. Be concise.",
|
||||
name="LocalAgent",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Anthropic managed runtime — full agent loop runs in Anthropic's cloud.
|
||||
|
||||
The ENTIRE loop (LLM calls, tool execution, memory, sessions) runs remotely
|
||||
on Anthropic's managed infrastructure. This is the only provider today where
|
||||
"the whole thing runs in that cloud".
|
||||
|
||||
Requires: ANTHROPIC_API_KEY environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.getenv("ANTHROPIC_API_KEY"):
|
||||
print("[skip] ANTHROPIC_API_KEY not set — set it to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
import anthropic # noqa: F401 (required by AnthropicManagedAgent)
|
||||
except ImportError:
|
||||
print("[skip] 'anthropic' SDK not installed — pip install 'anthropic>=0.94.0'")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, ManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="anthropic",
|
||||
config=ManagedConfig(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
system="You are a concise coding assistant. One-sentence answers.",
|
||||
name="AnthropicRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="anthropic-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work entirely in the cloud
|
||||
print("[1] Agent:", agent.start("Write a Python one-liner that sums 1..10. Just the code."))
|
||||
|
||||
# 2. Multi-turn reuses the same cloud session
|
||||
print("[2] Agent:", agent.start("Now change it to factorial of 5."))
|
||||
|
||||
# 3. Cloud-side session metadata
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} status={info.get('status')} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Agent runtime with tools sandboxed in a Daytona cloud workspace.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution runs in a Daytona-
|
||||
managed workspace (disposable cloud dev environment, pre-configured images).
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``DAYTONA_API_KEY`` for the Daytona API
|
||||
- ``pip install daytona-sdk``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("DAYTONA_API_KEY"):
|
||||
print("[skip] DAYTONA_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="daytona", # tool execution runs in a Daytona workspace
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="DaytonaRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="daytona-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] Daytona workspace: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(len([1,2,3,4,5]) ** 3)'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is 5 cubed? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Daytona workspace shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Agent runtime with tools sandboxed in a local Docker container.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution is isolated in a
|
||||
Docker container. Not a cloud provider, but the same pattern — useful for
|
||||
local development and CI.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- Docker daemon running (``docker info`` must succeed)
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
check=True, capture_output=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
print("[skip] Docker daemon not available — start Docker Desktop.")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="docker", # tool execution runs in a local Docker container
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="DockerRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="docker-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(image="python:3.12-slim")
|
||||
print(f"[1] Docker container: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(sum(i*i for i in range(1, 11)))'")
|
||||
print(f"[2] Container compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is the sum of squares from 1 to 10? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Docker container shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Agent runtime with tools sandboxed in an E2B cloud VM.
|
||||
|
||||
The agent's LLM loop runs locally; any tool / code execution is sandboxed
|
||||
in an ephemeral E2B cloud instance. Uses ``SandboxedAgent`` — the honest
|
||||
name for the "local loop + cloud tools" pattern.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``E2B_API_KEY`` for the cloud sandbox
|
||||
- ``pip install e2b``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("E2B_API_KEY"):
|
||||
print("[skip] E2B_API_KEY not set — set it to run in the E2B cloud sandbox.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="e2b", # tool execution runs in E2B cloud
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="E2BRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="e2b-runtime", backend=sandboxed)
|
||||
|
||||
# 1. Provision the cloud sandbox
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] E2B sandbox: {info.instance_id} ({info.status})")
|
||||
|
||||
# 2. Execute code IN the cloud sandbox
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(sum(range(1, 11)))'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
# 3. Agent loop (LLM local, any tool calls auto-sandboxed in E2B)
|
||||
print("[3] Agent:", agent.start("What is 13 * 17? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] E2B sandbox shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Agent runtime with tools sandboxed on Fly.io Machines.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution runs in a Fly.io
|
||||
Machine (fast-booting micro-VM with per-second billing and global regions).
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``FLY_API_TOKEN`` for the Fly.io Machines API
|
||||
- ``FLY_APP_NAME`` (optional) existing Fly app; auto-created otherwise
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("FLY_API_TOKEN"):
|
||||
print("[skip] FLY_API_TOKEN not set — get one via `fly auth token`.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="flyio", # tool execution runs on Fly.io Machines
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="FlyioRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="fly-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(
|
||||
image="python:3.12-slim",
|
||||
cpu=1,
|
||||
memory_mb=512,
|
||||
idle_timeout_s=120,
|
||||
)
|
||||
print(f"[1] Fly Machine: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(2 ** 20)'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is 2 raised to the 20th power? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Fly Machine shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Gemini managed runtime — agent loop local, LLM calls go to Google's cloud.
|
||||
|
||||
``ManagedAgent(provider="gemini")`` wires a local managed loop to Gemini's
|
||||
hosted models via litellm.
|
||||
|
||||
Requires: GEMINI_API_KEY (or GOOGLE_API_KEY) environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")):
|
||||
print("[skip] GEMINI_API_KEY / GOOGLE_API_KEY not set — set one to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="gemini",
|
||||
config=LocalManagedConfig(
|
||||
# Use explicit litellm prefix to guarantee routing to Google
|
||||
model="gemini/gemini-2.0-flash-exp",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="GeminiRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="gemini-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to Google cloud)
|
||||
print("[1] Agent:", agent.start("What does REST stand for? One sentence."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("How is it different from GraphQL?"))
|
||||
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Anthropic hosted runtime — entire agent runs on Anthropic's managed infrastructure.
|
||||
|
||||
Uses the new canonical HostedAgent class which clearly communicates that the entire
|
||||
agent loop runs in Anthropic's cloud, not locally.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not (os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY")):
|
||||
print("[skip] ANTHROPIC_API_KEY or CLAUDE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
print("[skip] anthropic SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import HostedAgent, HostedAgentConfig
|
||||
|
||||
# Create a hosted agent running entirely on Anthropic's managed runtime
|
||||
hosted = HostedAgent(
|
||||
provider="anthropic",
|
||||
config=HostedAgentConfig(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="AnthropicHostedAgent",
|
||||
tools=[{"type": "agent_toolset_20260401"}],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="anthropic-hosted", backend=hosted)
|
||||
|
||||
# 1. Basic execution - runs entirely in Anthropic's cloud
|
||||
print("[1] Hosted execution on Anthropic infrastructure...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata from Anthropic's API
|
||||
print(f"\n[2] Agent ID: {hosted.agent_id}")
|
||||
print(f" Version: {hosted.agent_version}")
|
||||
print(f" Env ID: {hosted.environment_id}")
|
||||
print(f" Session: {hosted.session_id}")
|
||||
|
||||
# 3. Multi-turn (same session keeps context in Anthropic's cloud)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking from Anthropic's usage API
|
||||
info = hosted.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. List all sessions for this agent
|
||||
sessions = hosted.list_sessions()
|
||||
print(f"\n[5] Sessions: {len(sessions)}")
|
||||
for s in sessions[:3]: # Show first 3
|
||||
print(f" {s['id']} | {s['status']}")
|
||||
|
||||
print("\nDone! Agent loop ran entirely on Anthropic's managed infrastructure.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with Gemini LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to Google's Gemini API, but there's no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")):
|
||||
print("[skip] GEMINI_API_KEY or GOOGLE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import google.generativeai
|
||||
except ImportError:
|
||||
print("[skip] google-generativeai SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using Gemini LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gemini/gemini-2.0-flash", # Use Gemini with litellm routing prefix
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalGeminiAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-gemini", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to Gemini
|
||||
print("[1] Local execution with Gemini LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello_gemini.txt with 'Hello from Gemini agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with Gemini LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with Ollama LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to a local Ollama instance, no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
# Check if Ollama daemon is running
|
||||
try:
|
||||
response = requests.get("http://localhost:11434/api/tags", timeout=2)
|
||||
if response.status_code != 200:
|
||||
raise Exception("Ollama not responding")
|
||||
except Exception:
|
||||
print("[skip] Ollama daemon not running at localhost:11434")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using Ollama LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="ollama/llama3.2", # Use Ollama with litellm routing prefix
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalOllamaAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-ollama", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to local Ollama
|
||||
print("[1] Local execution with Ollama LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello_ollama.txt with 'Hello from Ollama agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with Ollama LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with OpenAI LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to OpenAI, but there's no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
print("[skip] openai SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using OpenAI's LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalOpenAIAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-openai", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to OpenAI
|
||||
print("[1] Local execution with OpenAI LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello.txt with 'Hello from local agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with OpenAI LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Agent runtime with tools sandboxed in Modal cloud functions.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution is sandboxed in
|
||||
Modal's serverless cloud. Modal gives fast cold-starts and per-second billing.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- Modal CLI configured: ``modal token set --token-id ... --token-secret ...``
|
||||
- ``pip install modal``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
import modal # noqa: F401
|
||||
except ImportError:
|
||||
print("[skip] 'modal' package not installed — pip install modal")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
# Modal auth: presence of ~/.modal.toml or MODAL_TOKEN_ID + MODAL_TOKEN_SECRET
|
||||
if not (os.path.exists(os.path.expanduser("~/.modal.toml"))
|
||||
or (os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"))):
|
||||
print("[skip] Modal not configured — run `modal token set ...`")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="modal", # tool execution runs in Modal's cloud
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="ModalRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="modal-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] Modal instance: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'import math; print(math.factorial(10))'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is the 10th Fibonacci number? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Modal instance shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Ollama managed runtime — agent runs against a local/self-hosted LLM.
|
||||
|
||||
``ManagedAgent(provider="ollama")`` wires the managed loop to an Ollama
|
||||
server. Model runs locally (or on a self-hosted box) instead of in a public
|
||||
cloud — useful when you want the "managed agent" developer experience with
|
||||
no external API dependency.
|
||||
|
||||
Requires: An Ollama server reachable (default ``http://localhost:11434``)
|
||||
with at least one model pulled (e.g., ``ollama pull llama3.2``).
|
||||
"""
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||
try:
|
||||
urllib.request.urlopen(f"{base}/api/tags", timeout=2).read()
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"[skip] Ollama not reachable at {base} ({e}). Start it with `ollama serve`.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="ollama",
|
||||
config=LocalManagedConfig(
|
||||
model="llama3.2",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="OllamaRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="ollama-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to your Ollama server)
|
||||
print("[1] Agent:", agent.start("What is the capital of Japan? One word."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("Name its most famous mountain."))
|
||||
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id}")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""OpenAI managed runtime — agent loop local, LLM calls go to OpenAI cloud.
|
||||
|
||||
``ManagedAgent(provider="openai")`` wires a local managed loop to OpenAI's
|
||||
hosted models. The LLM reasoning (the heavy-lifting) runs in OpenAI's cloud;
|
||||
the agent orchestration (tool routing, session state) runs in this process.
|
||||
|
||||
Requires: OPENAI_API_KEY environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set — set it to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="openai",
|
||||
config=LocalManagedConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="OpenAIRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="openai-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to OpenAI cloud)
|
||||
print("[1] Agent:", agent.start("What is the time complexity of binary search? One sentence."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("And for a linear scan?"))
|
||||
|
||||
# 3. Local session metadata (cloud usage accumulated)
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Complete n8n Integration Example for PraisonAI
|
||||
|
||||
This example demonstrates the complete bidirectional n8n ↔ PraisonAI integration:
|
||||
|
||||
1. PraisonAI → n8n: Agents executing n8n workflows (using PraisonAI-Tools)
|
||||
2. n8n → PraisonAI: n8n workflows invoking PraisonAI agents (using API endpoint)
|
||||
|
||||
Prerequisites:
|
||||
1. Install dependencies:
|
||||
pip install "praisonai-tools[n8n]" fastapi uvicorn
|
||||
|
||||
2. Set up n8n instance:
|
||||
docker run -it --rm --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n
|
||||
|
||||
3. Environment variables:
|
||||
export N8N_URL="http://localhost:5678"
|
||||
export N8N_API_KEY="your-api-key" # optional for local testing
|
||||
|
||||
Usage:
|
||||
python examples/python/n8n_integration_example.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def setup_praisonai_agents():
|
||||
"""Set up PraisonAI agents with n8n tools."""
|
||||
print("🚀 Setting up PraisonAI agents with n8n capabilities...")
|
||||
|
||||
try:
|
||||
from praisonai_tools.n8n import n8n_workflow, n8n_list_workflows
|
||||
|
||||
# Create automation agent with n8n tools
|
||||
automation_agent = Agent(
|
||||
name="automation-agent",
|
||||
instructions="""
|
||||
You are an automation specialist with access to n8n's 400+ integrations.
|
||||
You can help users automate workflows across platforms like:
|
||||
|
||||
- Communication: Slack, Discord, Telegram, Gmail, Teams
|
||||
- Productivity: Notion, Google Sheets, Airtable, Trello
|
||||
- Databases: PostgreSQL, MongoDB, MySQL, Redis
|
||||
- APIs: REST, GraphQL, webhooks
|
||||
|
||||
When asked to perform automation tasks:
|
||||
1. First list available n8n workflows if needed
|
||||
2. Execute the appropriate workflow with the provided data
|
||||
3. Explain what the workflow accomplished
|
||||
|
||||
Always be helpful and provide clear explanations.
|
||||
""",
|
||||
tools=[n8n_workflow, n8n_list_workflows],
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Create notification agent focused on messaging
|
||||
notification_agent = Agent(
|
||||
name="notification-agent",
|
||||
instructions="""
|
||||
You specialize in sending notifications and messages across platforms.
|
||||
You have access to n8n workflows for various messaging platforms.
|
||||
|
||||
When asked to send notifications:
|
||||
1. Determine the best platform for the message
|
||||
2. Use appropriate n8n workflow for that platform
|
||||
3. Include relevant context and formatting
|
||||
|
||||
Be efficient and reliable with notifications.
|
||||
""",
|
||||
tools=[n8n_workflow],
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
return {
|
||||
"automation": automation_agent,
|
||||
"notification": notification_agent
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Error importing n8n tools: {e}")
|
||||
print("💡 Install with: pip install 'praisonai-tools[n8n]'")
|
||||
return {}
|
||||
|
||||
|
||||
def setup_api_server_with_agents(agents):
|
||||
"""Set up FastAPI server with agent invoke endpoints."""
|
||||
print("🌐 Setting up API server for n8n → PraisonAI integration...")
|
||||
|
||||
from praisonai.api.agent_invoke import register_agent, router
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="PraisonAI n8n Integration Server",
|
||||
description="API server for n8n to invoke PraisonAI agents",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Include agent invoke router
|
||||
app.include_router(router)
|
||||
|
||||
# Register agents
|
||||
for agent_id, agent in agents.items():
|
||||
register_agent(agent_id, agent)
|
||||
print(f"📋 Registered agent: {agent_id}")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with integration information."""
|
||||
return {
|
||||
"message": "PraisonAI n8n Integration Server",
|
||||
"available_agents": list(agents.keys()),
|
||||
"endpoints": {
|
||||
"invoke_agent": "/api/v1/agents/{agent_id}/invoke",
|
||||
"list_agents": "/api/v1/agents",
|
||||
"agent_info": "/api/v1/agents/{agent_id}"
|
||||
},
|
||||
"n8n_integration": {
|
||||
"description": "Use HTTP Request node in n8n to invoke agents",
|
||||
"example_url": "http://localhost:8000/api/v1/agents/automation/invoke",
|
||||
"example_body": {
|
||||
"message": "Send a Slack message to #general saying 'Hello from n8n!'",
|
||||
"session_id": "n8n-workflow-123"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def demo_praisonai_to_n8n(agents):
|
||||
"""Demonstrate PraisonAI agents calling n8n workflows."""
|
||||
print("\n🔄 Demo: PraisonAI → n8n Integration")
|
||||
print("=" * 50)
|
||||
|
||||
if not agents:
|
||||
print("❌ No agents available for demo")
|
||||
return
|
||||
|
||||
automation_agent = agents.get("automation")
|
||||
if not automation_agent:
|
||||
print("❌ Automation agent not available")
|
||||
return
|
||||
|
||||
try:
|
||||
print("📋 Testing agent with n8n workflow listing...")
|
||||
response = automation_agent.start(
|
||||
"Can you list the available n8n workflows?"
|
||||
)
|
||||
print(f"🤖 Agent: {response}")
|
||||
|
||||
print("\n🚀 Testing workflow execution...")
|
||||
response = automation_agent.start(
|
||||
"I need to send a notification that our deployment was successful. "
|
||||
"Can you help me send this via Slack to the #deployments channel?"
|
||||
)
|
||||
print(f"🤖 Agent: {response}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Demo error: {e}")
|
||||
|
||||
|
||||
async def demo_n8n_to_praisonai():
|
||||
"""Demonstrate n8n invoking PraisonAI agents via HTTP."""
|
||||
print("\n🔄 Demo: n8n → PraisonAI Integration")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Simulate n8n HTTP Request node call
|
||||
print("📡 Simulating n8n HTTP Request node call...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"http://localhost:8000/api/v1/agents/notification/invoke",
|
||||
json={
|
||||
"message": "A new user has signed up: [email protected]. Please send a welcome email.",
|
||||
"session_id": "n8n-user-signup-workflow"
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ Success: {data['result']}")
|
||||
print(f"📊 Session: {data['session_id']}")
|
||||
else:
|
||||
print(f"❌ Error: {response.status_code} - {response.text}")
|
||||
|
||||
except httpx.ConnectError:
|
||||
print("❌ Connection error: Make sure the API server is running")
|
||||
print("💡 Start server in another terminal: python examples/python/n8n_integration_example.py --server")
|
||||
except Exception as e:
|
||||
print(f"❌ Demo error: {e}")
|
||||
|
||||
|
||||
def create_n8n_workflow_examples():
|
||||
"""Show examples of n8n workflow configurations."""
|
||||
print("\n📝 n8n Workflow Configuration Examples")
|
||||
print("=" * 50)
|
||||
|
||||
print("""
|
||||
🔹 HTTP Request Node (n8n → PraisonAI):
|
||||
Method: POST
|
||||
URL: http://localhost:8000/api/v1/agents/automation/invoke
|
||||
Body:
|
||||
{
|
||||
"message": "{{ $json.user_message }}",
|
||||
"session_id": "{{ $json.session_id || workflow.id }}"
|
||||
}
|
||||
|
||||
🔹 Webhook Trigger for PraisonAI response:
|
||||
URL: http://localhost:8000/webhook/praisonai-response
|
||||
Method: POST
|
||||
|
||||
🔹 Slack notification after agent response:
|
||||
Channel: {{ $json.channel || '#general' }}
|
||||
Message: Agent response: {{ $json.agent_result }}
|
||||
""")
|
||||
|
||||
|
||||
async def run_interactive_demo():
|
||||
"""Run interactive demo of the integration."""
|
||||
print("\n🎮 Interactive Demo")
|
||||
print("=" * 20)
|
||||
|
||||
# Set up agents
|
||||
agents = setup_praisonai_agents()
|
||||
if not agents:
|
||||
print("❌ Cannot run interactive demo without agents")
|
||||
return
|
||||
|
||||
automation_agent = agents.get("automation")
|
||||
if not automation_agent:
|
||||
print("❌ Automation agent not available")
|
||||
return
|
||||
|
||||
print("💬 Chat with the automation agent (type 'quit' to exit)")
|
||||
print("💡 Try: 'List available n8n workflows'")
|
||||
print("💡 Try: 'Send a Slack message to #general'")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("\n👤 You: ").strip()
|
||||
if user_input.lower() in ['quit', 'exit', 'q']:
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
print("🤖 Agent: ", end="", flush=True)
|
||||
response = automation_agent.start(user_input)
|
||||
print(response)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Goodbye!")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
|
||||
|
||||
def run_server(port=8000):
|
||||
"""Run the API server for n8n integration."""
|
||||
print(f"\n🚀 Starting PraisonAI n8n Integration Server on port {port}...")
|
||||
|
||||
# Set up agents
|
||||
agents = setup_praisonai_agents()
|
||||
|
||||
# Create app
|
||||
app = setup_api_server_with_agents(agents)
|
||||
|
||||
print(f"✅ Server ready at http://localhost:{port}")
|
||||
print(f"📊 Available agents: {list(agents.keys())}")
|
||||
print("\n🔗 n8n HTTP Request Node Configuration:")
|
||||
print(f" URL: http://localhost:{port}/api/v1/agents/{{agent_id}}/invoke")
|
||||
print(" Method: POST")
|
||||
print(' Body: {"message": "Your message here", "session_id": "optional"}')
|
||||
|
||||
# Run server
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to run demos."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="PraisonAI n8n Integration Demo")
|
||||
parser.add_argument("--server", action="store_true", help="Run API server")
|
||||
parser.add_argument("--demo", action="store_true", help="Run demos")
|
||||
parser.add_argument("--interactive", action="store_true", help="Run interactive demo")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Server port")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.server:
|
||||
run_server(args.port)
|
||||
elif args.interactive:
|
||||
await run_interactive_demo()
|
||||
elif args.demo:
|
||||
# Set up agents for demos
|
||||
agents = setup_praisonai_agents()
|
||||
|
||||
# Run demos
|
||||
await demo_praisonai_to_n8n(agents)
|
||||
create_n8n_workflow_examples()
|
||||
print("\n💡 To test n8n → PraisonAI, start the server:")
|
||||
print(" python examples/python/n8n_integration_example.py --server")
|
||||
print(" Then run: python examples/python/n8n_integration_example.py --test-api")
|
||||
|
||||
else:
|
||||
# Default: show info and run basic demo
|
||||
print("🔗 PraisonAI ↔ n8n Bidirectional Integration")
|
||||
print("=" * 50)
|
||||
|
||||
print("\n📋 Integration Components:")
|
||||
print("1. 🔧 PraisonAI-Tools: n8n workflow execution tools")
|
||||
print("2. 🌐 API Endpoint: Agent invoke endpoint for n8n")
|
||||
print("3. 🤖 Agents: PraisonAI agents with n8n capabilities")
|
||||
|
||||
print("\n🚀 Available Commands:")
|
||||
print(" --demo Run integration demos")
|
||||
print(" --server Start API server for n8n")
|
||||
print(" --interactive Run interactive agent chat")
|
||||
|
||||
# Quick demo
|
||||
agents = setup_praisonai_agents()
|
||||
if agents:
|
||||
await demo_praisonai_to_n8n(agents)
|
||||
create_n8n_workflow_examples()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remote Sandbox Examples for PraisonAI
|
||||
|
||||
Demonstrates how to use SSH, Modal, and Daytona sandbox backends
|
||||
for remote code execution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from praisonai.sandbox import SSHSandbox, ModalSandbox, DaytonaSandbox
|
||||
from praisonaiagents.sandbox import ResourceLimits
|
||||
|
||||
|
||||
async def ssh_example():
|
||||
"""Example using SSH sandbox for remote execution."""
|
||||
print("🔗 SSH Sandbox Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Configure SSH connection
|
||||
# Note: You need actual SSH server access for this to work
|
||||
ssh_host = os.getenv("SSH_HOST", "localhost")
|
||||
ssh_user = os.getenv("SSH_USER", "ubuntu")
|
||||
ssh_key = os.getenv("SSH_KEY_FILE", "~/.ssh/id_rsa")
|
||||
|
||||
print(f"Connecting to {ssh_user}@{ssh_host}")
|
||||
|
||||
try:
|
||||
sandbox = SSHSandbox(
|
||||
host=ssh_host,
|
||||
user=ssh_user,
|
||||
key_file=ssh_key,
|
||||
working_dir="/tmp/praisonai_demo"
|
||||
)
|
||||
|
||||
# Check availability
|
||||
if not sandbox.is_available:
|
||||
print("❌ SSH backend not available. Install with: pip install praisonai[ssh]")
|
||||
return
|
||||
|
||||
# Start sandbox
|
||||
await sandbox.start()
|
||||
print("✅ SSH connection established")
|
||||
|
||||
# Execute Python code
|
||||
print("\n📄 Executing Python code...")
|
||||
result = await sandbox.execute("""
|
||||
import sys
|
||||
import platform
|
||||
print(f"Python version: {sys.version}")
|
||||
print(f"Platform: {platform.platform()}")
|
||||
print("Hello from remote SSH server! 🚀")
|
||||
""", language="python")
|
||||
|
||||
print(f"Exit code: {result.exit_code}")
|
||||
print(f"Output:\n{result.stdout}")
|
||||
if result.stderr:
|
||||
print(f"Errors:\n{result.stderr}")
|
||||
|
||||
# Execute shell command
|
||||
print("\n🐚 Executing shell command...")
|
||||
result = await sandbox.run_command("uname -a && uptime")
|
||||
print(f"System info:\n{result.stdout}")
|
||||
|
||||
# Write and read file
|
||||
print("\n📝 File operations...")
|
||||
await sandbox.write_file("/tmp/praisonai_demo/hello.py", "print('Hello from file!')")
|
||||
content = await sandbox.read_file("/tmp/praisonai_demo/hello.py")
|
||||
print(f"File content: {content}")
|
||||
|
||||
# Execute the file
|
||||
result = await sandbox.execute_file("/tmp/praisonai_demo/hello.py")
|
||||
print(f"File execution result: {result.stdout}")
|
||||
|
||||
# Test resource limits
|
||||
print("\n⏱️ Testing resource limits...")
|
||||
limits = ResourceLimits(timeout_seconds=5, memory_mb=128)
|
||||
result = await sandbox.execute("""
|
||||
import time
|
||||
print("Starting long task...")
|
||||
time.sleep(10) # This will timeout
|
||||
print("Task completed")
|
||||
""", limits=limits)
|
||||
|
||||
print(f"Limited execution status: {result.status}")
|
||||
if result.error:
|
||||
print(f"Error (expected timeout): {result.error}")
|
||||
|
||||
await sandbox.stop()
|
||||
print("✅ SSH sandbox stopped")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ SSH example failed: {e}")
|
||||
|
||||
|
||||
async def modal_example():
|
||||
"""Example using Modal sandbox for serverless GPU execution."""
|
||||
print("\n☁️ Modal Sandbox Example")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Note: Requires Modal account and API key
|
||||
sandbox = ModalSandbox(
|
||||
gpu="A100", # Request A100 GPU
|
||||
timeout=120
|
||||
)
|
||||
|
||||
# Check availability
|
||||
if not sandbox.is_available:
|
||||
print("❌ Modal backend not available. Install with: pip install praisonai[modal]")
|
||||
return
|
||||
|
||||
print("🚀 Starting Modal app...")
|
||||
await sandbox.start()
|
||||
print("✅ Modal app initialized")
|
||||
|
||||
# Execute GPU-accelerated Python code
|
||||
print("\n🔥 Executing GPU code...")
|
||||
result = await sandbox.execute("""
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
print(f"PyTorch version: {torch.__version__}")
|
||||
print(f"CUDA available: {torch.cuda.is_available()}")
|
||||
|
||||
if torch.cuda.is_available():
|
||||
device = torch.cuda.get_device_name()
|
||||
print(f"GPU device: {device}")
|
||||
|
||||
# Simple tensor operations on GPU
|
||||
x = torch.randn(1000, 1000, device='cuda')
|
||||
y = torch.randn(1000, 1000, device='cuda')
|
||||
z = torch.matmul(x, y)
|
||||
print(f"GPU computation result shape: {z.shape}")
|
||||
else:
|
||||
print("Running on CPU")
|
||||
x = torch.randn(100, 100)
|
||||
y = torch.randn(100, 100)
|
||||
z = torch.matmul(x, y)
|
||||
print(f"CPU computation result shape: {z.shape}")
|
||||
|
||||
print("🚀 Modal execution completed!")
|
||||
""", language="python")
|
||||
|
||||
print(f"Exit code: {result.exit_code}")
|
||||
print(f"Output:\n{result.stdout}")
|
||||
if result.stderr:
|
||||
print(f"Errors:\n{result.stderr}")
|
||||
|
||||
# Test different languages
|
||||
print("\n🐚 Testing bash execution...")
|
||||
result = await sandbox.run_command("nvidia-smi || echo 'No GPU info available'")
|
||||
print(f"GPU info:\n{result.stdout}")
|
||||
|
||||
await sandbox.stop()
|
||||
print("✅ Modal sandbox stopped")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Modal example failed: {e}")
|
||||
|
||||
|
||||
async def daytona_example():
|
||||
"""Example using Daytona sandbox for cloud dev environment."""
|
||||
print("\n🌤️ Daytona Sandbox Example")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Configure Daytona workspace
|
||||
sandbox = DaytonaSandbox(
|
||||
workspace_template="python-dev",
|
||||
provider="aws", # or "gcp", "azure", "local"
|
||||
timeout=180
|
||||
)
|
||||
|
||||
# Check availability
|
||||
if not sandbox.is_available:
|
||||
print("❌ Daytona backend not available. Install with: pip install praisonai[daytona]")
|
||||
return
|
||||
|
||||
print(f"🏗️ Creating Daytona workspace: {sandbox.workspace_name}")
|
||||
await sandbox.start()
|
||||
print("✅ Daytona workspace ready")
|
||||
|
||||
# Execute development tasks
|
||||
print("\n💻 Running development tasks...")
|
||||
result = await sandbox.execute("""
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
print("🐍 Python Development Environment")
|
||||
print(f"Python version: {sys.version}")
|
||||
print(f"Working directory: {os.getcwd()}")
|
||||
|
||||
# Check installed packages
|
||||
try:
|
||||
result = subprocess.run([sys.executable, "-m", "pip", "list"],
|
||||
capture_output=True, text=True)
|
||||
print("📦 Installed packages:")
|
||||
print(result.stdout[:500] + "..." if len(result.stdout) > 500 else result.stdout)
|
||||
except Exception as e:
|
||||
print(f"Could not list packages: {e}")
|
||||
|
||||
print("✨ Daytona workspace is ready for development!")
|
||||
""", language="python")
|
||||
|
||||
print(f"Exit code: {result.exit_code}")
|
||||
print(f"Output:\n{result.stdout}")
|
||||
|
||||
# Test file operations
|
||||
print("\n📁 Workspace file operations...")
|
||||
await sandbox.write_file("/workspace/demo.py", """
|
||||
def hello_daytona():
|
||||
print("Hello from Daytona workspace!")
|
||||
return "🎉 Success!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = hello_daytona()
|
||||
print(result)
|
||||
""")
|
||||
|
||||
# Execute the file
|
||||
result = await sandbox.execute_file("/workspace/demo.py")
|
||||
print(f"Demo script output: {result.stdout}")
|
||||
|
||||
# List workspace files
|
||||
files = await sandbox.list_files("/workspace")
|
||||
print(f"Workspace files: {files}")
|
||||
|
||||
await sandbox.stop()
|
||||
print("✅ Daytona workspace stopped")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Daytona example failed: {e}")
|
||||
|
||||
|
||||
async def comparison_example():
|
||||
"""Compare execution across different sandbox backends."""
|
||||
print("\n⚖️ Sandbox Comparison")
|
||||
print("=" * 50)
|
||||
|
||||
# Simple test code
|
||||
test_code = """
|
||||
import time
|
||||
import sys
|
||||
start_time = time.time()
|
||||
print(f"Hello from {sys.platform}!")
|
||||
print(f"Execution time: {time.time() - start_time:.3f}s")
|
||||
"""
|
||||
|
||||
# Test each sandbox type
|
||||
sandboxes = [
|
||||
("SSH", SSHSandbox(host=os.getenv("SSH_HOST", "localhost"))),
|
||||
("Modal", ModalSandbox()),
|
||||
("Daytona", DaytonaSandbox())
|
||||
]
|
||||
|
||||
for name, sandbox in sandboxes:
|
||||
print(f"\n🧪 Testing {name} sandbox...")
|
||||
|
||||
if not sandbox.is_available:
|
||||
print(f"❌ {name} not available")
|
||||
continue
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
result = await sandbox.execute(test_code, language="python")
|
||||
|
||||
print(f"✅ {name}: {result.status}")
|
||||
print(f" Duration: {result.duration_seconds:.3f}s")
|
||||
print(f" Output: {result.stdout.strip()}")
|
||||
|
||||
await sandbox.stop()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ {name} failed: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all examples."""
|
||||
print("🚀 PraisonAI Remote Sandbox Examples")
|
||||
print("=" * 70)
|
||||
|
||||
# Run examples
|
||||
await ssh_example()
|
||||
await modal_example()
|
||||
await daytona_example()
|
||||
await comparison_example()
|
||||
|
||||
print("\n✅ All examples completed!")
|
||||
print("\nNext steps:")
|
||||
print("1. Set up SSH access to remote servers")
|
||||
print("2. Configure Modal account for GPU workloads")
|
||||
print("3. Set up Daytona for cloud development")
|
||||
print("4. Use these sandboxes in your PraisonAI agents!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Motion Graphics — Basic HTML/GSAP → MP4 render (no LLM required).
|
||||
|
||||
This example drives the `HtmlRenderBackend` directly with a hand-authored
|
||||
HTML/GSAP composition. It renders to a real MP4 via headless Chromium +
|
||||
bundled ffmpeg, with no API keys needed.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai-tools[video-motion]
|
||||
playwright install chromium
|
||||
|
||||
Verified end-to-end: produces 1920x1080 @ 30fps H.264 MP4 in ~5–10 seconds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai_tools.video.motion_graphics import HtmlRenderBackend, RenderOpts
|
||||
|
||||
|
||||
HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body { margin:0; padding:0; background:#0f172a; overflow:hidden;
|
||||
font-family: -apple-system, Arial, sans-serif; }
|
||||
#stage { width:1920px; height:1080px; position:relative; }
|
||||
.title { font-size:96px; font-weight:800; color:#f8fafc;
|
||||
position:absolute; top:50%; left:50%;
|
||||
transform:translate(-50%,-50%); opacity:0; }
|
||||
.subtitle { font-size:44px; color:#94a3b8;
|
||||
position:absolute; top:62%; left:50%;
|
||||
transform:translate(-50%,-50%); opacity:0; }
|
||||
.dot { width:32px; height:32px; border-radius:50%; background:#38bdf8;
|
||||
position:absolute; top:78%; left:50%;
|
||||
transform:translate(-50%,-50%); opacity:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage" data-duration="4.0">
|
||||
<div class="title">PraisonAI</div>
|
||||
<div class="subtitle">Motion Graphics Pipeline</div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".title", { duration: 0.8, opacity: 1, y: -30, ease: "power3.out" })
|
||||
.to(".subtitle", { duration: 0.7, opacity: 1, y: -20, ease: "power3.out" }, "-=0.3")
|
||||
.to(".dot", { duration: 0.5, opacity: 1, scale: 2, ease: "back.out(2)" }, "-=0.2")
|
||||
.to(".dot", { duration: 0.4, x: 200, ease: "power2.inOut" })
|
||||
.to(".dot", { duration: 0.4, x: -200, ease: "power2.inOut" })
|
||||
.to(".dot", { duration: 0.4, x: 0, ease: "power2.inOut" })
|
||||
.to([".title",".subtitle",".dot"],
|
||||
{ duration: 0.5, opacity: 0, ease: "power2.in" });
|
||||
// Required: export timeline(s) for the render backend to drive.
|
||||
window.__timelines = [tl];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
out = Path("/tmp/motion_graphics_demo")
|
||||
out.mkdir(exist_ok=True)
|
||||
(out / "index.html").write_text(HTML)
|
||||
|
||||
backend = HtmlRenderBackend(base_dir=out)
|
||||
|
||||
lint = await backend.lint(out)
|
||||
print(f"Lint: ok={lint.ok} messages={lint.messages}")
|
||||
|
||||
opts = RenderOpts(output_name="praisonai_demo.mp4", fps=30, quality="standard")
|
||||
print("Render: ...", flush=True)
|
||||
result = await backend.render(out, opts)
|
||||
|
||||
if result.ok:
|
||||
print(f"Render: OK path={result.output_path} size={result.size_kb} KB")
|
||||
else:
|
||||
print(f"Render: FAIL stderr={result.stderr}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Motion Graphics — GitTools safety demo (no network required for this demo).
|
||||
|
||||
GitTools is the read-only toolkit used by the code-exploration specialist in
|
||||
`motion_graphics_team`. It clones repos on demand, validates paths against
|
||||
traversal, and restricts to GitHub URLs or `owner/repo` format.
|
||||
|
||||
This example exercises the pure-Python safety helpers (URL parsing + file
|
||||
path validation). Cloning is network-bound and demonstrated separately.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai-tools
|
||||
"""
|
||||
|
||||
from praisonai_tools.tools.git_tools import GitTools
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tools = GitTools(base_dir="/tmp/praison_git_repos_demo")
|
||||
|
||||
print("URL parsing")
|
||||
print("-----------")
|
||||
for repo_input in [
|
||||
"octocat/Hello-World", # owner/repo
|
||||
"https://github.com/octocat/Hello-World.git", # https URL
|
||||
"[email protected]:octocat/Hello-World.git", # ssh URL
|
||||
]:
|
||||
try:
|
||||
url, name = tools._parse_repo_input(repo_input)
|
||||
print(f" {repo_input:<50} -> name={name}")
|
||||
except ValueError as exc:
|
||||
print(f" {repo_input:<50} -> REJECTED: {exc}")
|
||||
|
||||
print("\nFile-path validation")
|
||||
print("--------------------")
|
||||
for path in [
|
||||
"README.md", # safe
|
||||
"src/main.py", # safe
|
||||
"../etc/passwd", # traversal
|
||||
"../../secret.txt", # traversal
|
||||
"/etc/passwd", # absolute
|
||||
]:
|
||||
try:
|
||||
safe = tools._validate_file_path(path)
|
||||
print(f" {path:<30} -> SAFE: {safe}")
|
||||
except ValueError as exc:
|
||||
print(f" {path:<30} -> REJECT: {exc}")
|
||||
|
||||
print("\nListing any previously cloned repos:", tools.list_repos())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Motion Graphics — single-agent factory (requires praisonaiagents + LLM key).
|
||||
|
||||
Creates a single motion-graphics authoring agent that writes HTML/GSAP and
|
||||
renders to MP4 via the HTML backend. Good for quick, one-shot prompts.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai-tools[video-motion] praisonaiagents[llm]
|
||||
playwright install chromium
|
||||
export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, etc.
|
||||
|
||||
Set a working model via env (LiteLLM routing):
|
||||
export MOTION_LLM=anthropic/claude-sonnet-4-20250514
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai_tools.video.motion_graphics import create_motion_graphics_agent
|
||||
|
||||
|
||||
def main() -> None:
|
||||
workspace = Path("/tmp/motion_graphics_agent_demo")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
llm = os.getenv("MOTION_LLM", "anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
agent = create_motion_graphics_agent(
|
||||
backend="html",
|
||||
workspace=workspace,
|
||||
max_retries=3,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
print(f"Agent: {agent.name}")
|
||||
print(f"Backend: {agent._motion_graphics_backend.__class__.__name__}")
|
||||
print(f"Workspace: {agent._motion_graphics_workspace}")
|
||||
print(f"Retries: {agent._motion_graphics_max_retries}")
|
||||
print(f"LLM: {llm}")
|
||||
print()
|
||||
print("Starting agent...\n")
|
||||
|
||||
agent.start(
|
||||
"Create a short title-card animation.\n"
|
||||
"Steps you MUST follow, in order:\n"
|
||||
" 1. Call write_file with filepath='index.html' and content containing "
|
||||
" a complete HTML document with GSAP 3.12 from cdnjs, a #stage div "
|
||||
" with data-duration='3.0', visible text 'Hello Motion' that fades "
|
||||
" in, and a cyan underline that draws across. The file MUST set "
|
||||
" window.__timelines = [tl] at the end of its <script> block, and "
|
||||
" the timeline MUST be created with { paused: true }.\n"
|
||||
" 2. Call lint_composition() and verify ok=True.\n"
|
||||
" 3. Call render_composition(output_name='intro.mp4', fps=30, "
|
||||
" quality='standard') and report the resulting output_path.\n"
|
||||
"Return the concrete output_path of the MP4 or the exact stderr."
|
||||
)
|
||||
|
||||
print("\nArtifacts in workspace:")
|
||||
for p in sorted(workspace.rglob("*")):
|
||||
if p.is_file():
|
||||
print(f" {p.relative_to(workspace)} ({p.stat().st_size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Motion Graphics — full team pipeline (requires praisonaiagents + LLM key).
|
||||
|
||||
Runs the coordinator + animator team (optionally with researcher and
|
||||
code-explorer specialists) under a hierarchical process, so the coordinator
|
||||
validates outputs before marking a render as successful.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai-tools[video-motion] praisonaiagents[llm]
|
||||
playwright install chromium
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export MOTION_LLM=anthropic/claude-sonnet-4-20250514
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai_tools.video.motion_graphics import motion_graphics_team
|
||||
|
||||
|
||||
def main() -> None:
|
||||
workspace = Path("/tmp/motion_graphics_team_demo")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
llm = os.getenv("MOTION_LLM", "anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
print(f"[setup] workspace={workspace} llm={llm}")
|
||||
|
||||
team = motion_graphics_team(
|
||||
research=False, # set True to add a web-search researcher
|
||||
code_exploration=False, # set True to add a GitTools code-explorer
|
||||
workspace=workspace,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
print(f"[setup] {len(team.agents)} agents: {[a.name for a in team.agents]}")
|
||||
|
||||
prompt = "Animate Dijkstra's algorithm on a small weighted graph, 30s."
|
||||
print(f"[run] prompt: {prompt}")
|
||||
|
||||
t0 = time.time()
|
||||
result = team.start(prompt)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
print(f"[done] elapsed={elapsed:.1f}s")
|
||||
print(f"[result] {result!r}")
|
||||
|
||||
print("\n[artifacts]")
|
||||
for p in sorted(workspace.rglob("*.mp4")):
|
||||
print(f" mp4 {p} ({p.stat().st_size} bytes)")
|
||||
for p in sorted(workspace.rglob("*.html")):
|
||||
print(f" html {p} ({p.stat().st_size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
framework: praisonai
|
||||
topic: coding
|
||||
roles:
|
||||
coder:
|
||||
role: Code refactorer
|
||||
goal: Refactor Python modules for better maintainability
|
||||
backstory: Senior engineer with expertise in clean code principles
|
||||
cli_backend: claude-code
|
||||
tasks:
|
||||
refactor:
|
||||
description: Refactor utils.py to improve code quality
|
||||
expected_output: Refactored code with improved structure and documentation
|
||||
@@ -0,0 +1,379 @@
|
||||
---
|
||||
description: Comprehensive analysis workflow that produces a detailed GitHub issue — combines deep codebase analysis with issue creation for implementation-ready tickets
|
||||
---
|
||||
|
||||
# Analysis → GitHub Issue Workflow
|
||||
|
||||
Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Issue.
|
||||
|
||||
> **When to use this:** You need to analyze a feature, integration, or system deeply and produce a GitHub issue that another agent/developer can implement immediately without asking follow-up questions.
|
||||
> **When NOT to use this:** Use `/analysis` for analysis-only (no issue). Use `/local-fix` if you're implementing yourself.
|
||||
|
||||
---
|
||||
|
||||
## PRINCIPLES (apply throughout)
|
||||
|
||||
- **Agent-centric**: Agents, workflows, sessions, tools, memory, multi-agent safety.
|
||||
- **Protocol-driven core**: praisonaiagents = lightweight, protocol-first (protocols/hooks/adapters only).
|
||||
- **DRY**: identify reuse; avoid duplication.
|
||||
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
|
||||
- **Async-safe + multi-agent safe** by default.
|
||||
- **Easy for non-developers**: "Few lines of code to do the task!"
|
||||
|
||||
---
|
||||
|
||||
## CANONICAL PATHS
|
||||
|
||||
```
|
||||
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
Tools: /Users/praison/PraisonAI-tools
|
||||
Docs: /Users/praison/PraisonAIDocs
|
||||
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
Extension: tools/base.py, tools/decorator.py, db/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Acceptance Criteria
|
||||
|
||||
Translate the request into testable criteria:
|
||||
- API expectations (classes, methods, signatures)
|
||||
- CLI expectations (commands, flags, help text)
|
||||
- Documentation expectations (pages, examples)
|
||||
- Test expectations (unit, integration, smoke, real agentic)
|
||||
- Performance constraints (import time, hot-path)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — External Research (if applicable)
|
||||
|
||||
### 2a. Read official sources
|
||||
|
||||
If analyzing an external integration, research from primary sources:
|
||||
|
||||
```bash
|
||||
# Use read_url_content tool for:
|
||||
# - Official site / announcement pages
|
||||
# - GitHub README and docs
|
||||
# - Framework documentation
|
||||
# - Third-party write-ups
|
||||
```
|
||||
|
||||
**Capture:**
|
||||
- What is it? Purpose and design goals
|
||||
- Key terminology and concepts
|
||||
- Architecture overview
|
||||
- Current ecosystem state
|
||||
- References list (all URLs)
|
||||
|
||||
### 2b. Clone & analyze external codebase (if applicable)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> ~/<repo-name> --depth=1
|
||||
ls -la ~/<repo-name>/
|
||||
```
|
||||
|
||||
Key files to read:
|
||||
- `AGENTS.md` / `README.md` — architecture overview
|
||||
- Base class files (abstract methods, signatures)
|
||||
- Factory / registry files (registration pattern)
|
||||
- Model/config files (data structures)
|
||||
- Example implementations (minimal + full-featured)
|
||||
- `pyproject.toml` / `package.json` — dependencies
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — PraisonAI Codebase Inventory
|
||||
|
||||
### 3a. Inventory with evidence
|
||||
|
||||
Go through ALL relevant files, understand current logic end-to-end.
|
||||
|
||||
```bash
|
||||
# Core SDK
|
||||
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
|
||||
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
|
||||
|
||||
# Wrapper
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
|
||||
|
||||
# TypeScript
|
||||
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
|
||||
```
|
||||
|
||||
**Document:**
|
||||
- Public APIs: modules/classes/functions and exports
|
||||
- Protocols/adapters/hooks and extension points
|
||||
- CLI commands, options, help text
|
||||
- Integrations (db/observability/tools)
|
||||
- Tests (unit/integration), fixtures, CI config
|
||||
- Docs (Mintlify), examples, recipes
|
||||
|
||||
Provide: **file paths + key symbols + grep counts + entry points**.
|
||||
|
||||
### 3b. Identify DRY opportunities
|
||||
|
||||
- Existing abstractions that can be reused
|
||||
- Duplicated code that should be consolidated
|
||||
- Existing protocols before creating new ones
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Detailed Analysis
|
||||
|
||||
### 4a. Architecture and behavior
|
||||
|
||||
- Control flow and data flow
|
||||
- Concurrency model (sync/async)
|
||||
- Multi-agent interactions
|
||||
- Dependency boundaries (core/wrapper/tools)
|
||||
- Failure modes and error handling
|
||||
|
||||
### 4b. Invariants check
|
||||
|
||||
Verify these are preserved:
|
||||
- [ ] Protocol-driven core (no heavy impls in praisonaiagents)
|
||||
- [ ] Lazy imports (optional deps never at module level)
|
||||
- [ ] Backward compatible (public API changes need deprecation)
|
||||
- [ ] Safe defaults (new features opt-in)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Gap Analysis
|
||||
|
||||
Compare acceptance criteria vs current state.
|
||||
|
||||
### Checklist
|
||||
|
||||
| Area | Current State | Gap | Severity | Impact | Placement |
|
||||
|------|---------------|-----|----------|--------|-----------|
|
||||
| Core SDK | | | | | |
|
||||
| Wrapper | | | | | |
|
||||
| Tools/plugins | | | | | |
|
||||
| CLI | | | | | |
|
||||
| Docs | | | | | |
|
||||
| Tests | | | | | |
|
||||
| Exports | | | | | |
|
||||
| Perf | | | | | |
|
||||
| Multi-agent + async | | | | | |
|
||||
|
||||
### Gap severity levels
|
||||
- **Critical**: Blocks functionality
|
||||
- **High**: Significant limitation
|
||||
- **Medium**: Missing feature
|
||||
- **Low**: Nice-to-have
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Critical Review
|
||||
|
||||
### 6a. Risks and footguns
|
||||
|
||||
- API ambiguity or edge cases
|
||||
- Backward compatibility concerns
|
||||
- Coupling violations
|
||||
- Performance regressions
|
||||
- Concurrency hazards
|
||||
- Misuse risks
|
||||
- Operational concerns
|
||||
|
||||
### 6b. Mitigation strategies
|
||||
|
||||
For each risk, document:
|
||||
- "Safe by default" recommendation
|
||||
- Explicit opt-in for dangerous behavior
|
||||
- Documentation warnings
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Implementation Plan
|
||||
|
||||
Step-by-step plan (DO NOT implement):
|
||||
|
||||
### 7a. Test-first (TDD)
|
||||
|
||||
1. Unit tests for new functionality
|
||||
2. Integration tests for cross-component behavior
|
||||
3. Smoke tests for quick validation
|
||||
4. **Real agentic tests** (REQUIRED):
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="You are helpful")
|
||||
result = agent.start("Say hello in one sentence")
|
||||
print(result) # Must call LLM and produce response
|
||||
```
|
||||
|
||||
### 7b. Implementation order
|
||||
|
||||
1. Tests (TDD)
|
||||
2. Core implementation
|
||||
3. CLI integration
|
||||
4. Documentation
|
||||
5. Verification
|
||||
|
||||
### 7c. Files to change/create
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| | | |
|
||||
|
||||
### 7d. Verification commands
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pytest tests/unit/test_<feature>.py -v
|
||||
|
||||
# Integration tests
|
||||
pytest tests/integration/test_<feature>.py -v
|
||||
|
||||
# Smoke test
|
||||
python -c "from praisonaiagents import <Feature>; print('OK')"
|
||||
|
||||
# Real agentic test
|
||||
python examples/<feature>_example.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Draft GitHub Issue
|
||||
|
||||
The issue must be **self-contained**. Another agent must be able to implement by reading only the issue.
|
||||
|
||||
### Required sections
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
One paragraph: what, why, and the end goal.
|
||||
|
||||
## Background
|
||||
- Purpose and design goals
|
||||
- Why this is valuable
|
||||
- Current state / ecosystem
|
||||
|
||||
## Architecture Analysis
|
||||
### Current Implementation
|
||||
- Key files and their purposes
|
||||
- Integration points
|
||||
- Data flow
|
||||
|
||||
### Key File Locations
|
||||
| File | Purpose | Lines |
|
||||
|------|---------|-------|
|
||||
|
||||
## Gap Analysis Summary
|
||||
### Critical Gaps
|
||||
| Gap | Impact | Effort |
|
||||
|-----|--------|--------|
|
||||
|
||||
### Feature Gaps
|
||||
| Feature | Current Support | Gap |
|
||||
|---------|-----------------|-----|
|
||||
|
||||
## Proposed Implementation
|
||||
### Phase 1: Minimal
|
||||
Working code example.
|
||||
|
||||
### Phase 2: Production
|
||||
Working code example.
|
||||
|
||||
## Files to Create / Modify
|
||||
### New files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
|
||||
### Modified files
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
|
||||
## Technical Considerations
|
||||
- Dependencies
|
||||
- Performance impact
|
||||
- Safety / approval
|
||||
- Multi-agent safety
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Verifiable checkbox list
|
||||
|
||||
## Implementation Notes
|
||||
### Key Files to Read First
|
||||
1. `path/to/file.py` (N lines) — purpose
|
||||
|
||||
### Critical Integration Points
|
||||
1. Where X connects to Y
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Copy-pastable commands
|
||||
```
|
||||
|
||||
## References
|
||||
- [Link](url)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Create GitHub Issue
|
||||
|
||||
### 9a. Create via `gh` CLI
|
||||
|
||||
```bash
|
||||
gh issue create \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--title "<Type>: <Descriptive Title>" \
|
||||
--body "<issue body>"
|
||||
```
|
||||
|
||||
**Title formats:**
|
||||
- `Feature: <What>` — new functionality
|
||||
- `Analysis: <Topic>` — analysis/research
|
||||
- `Integration: <External System>` — external integration
|
||||
- `Fix: <Problem>` — bug fix proposal
|
||||
|
||||
### 9b. Verify issue created
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo MervinPraison/PraisonAI | head -20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist — Before Creating Issue
|
||||
|
||||
- [ ] Acceptance criteria defined and testable
|
||||
- [ ] All relevant PraisonAI files read (not skimmed)
|
||||
- [ ] External sources researched (if applicable)
|
||||
- [ ] Gap analysis completed with severity ratings
|
||||
- [ ] Critical review identifies risks and mitigations
|
||||
- [ ] Implementation plan is step-by-step with file list
|
||||
- [ ] Issue body has working code examples (not pseudocode)
|
||||
- [ ] All file paths are absolute and verified
|
||||
- [ ] CLI commands are copy-pastable
|
||||
- [ ] Acceptance criteria are verifiable (not vague)
|
||||
- [ ] References section includes all URLs
|
||||
- [ ] Issue is self-contained — no "see docs" without details
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---------|-----|
|
||||
| Pseudocode in examples | Use real, runnable code with imports |
|
||||
| Vague file paths | Use absolute paths |
|
||||
| Skipping gap analysis | Every gap needs severity + impact |
|
||||
| Missing real agentic test | Include `agent.start()` test, not just construction |
|
||||
| No verification commands | Include copy-pastable pytest/python commands |
|
||||
| Claiming "done" without evidence | Every claim references specific files/symbols |
|
||||
| Implementing instead of analyzing | This workflow produces issues, not code |
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. **DO NOT implement** — this workflow produces analysis and issues only
|
||||
2. **DO NOT claim done** without evidence
|
||||
3. **Every claim must reference** specific files/symbols
|
||||
4. **Issue must be self-contained** — implementer needs no follow-up questions
|
||||
5. **Working code examples required** — no pseudocode
|
||||
@@ -0,0 +1,669 @@
|
||||
---
|
||||
description: End-to-end workflow — deep analysis → GitHub issue → wait for implementing agents → create PR if missing → wait for reviewing agents → review PR locally → feedback to @claude → merge to main when robust per AGENTS.md
|
||||
---
|
||||
|
||||
# End-to-End: Analysis → Issue → PR → Review → Merge
|
||||
|
||||
Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue → **Wait for implementing agents** → **Create PR if missing** → **Wait for reviewers** → **Validate PR locally** → **Feedback to @claude** → **Re-review after fixes** → **Merge to main if robust**.
|
||||
|
||||
> **When to use this:** You want a fully hands-off delivery loop. You describe a feature/integration/fix; this workflow produces the issue, monitors implementation by other agents (Claude, Copilot, CodeRabbit), validates their PR locally against `AGENTS.md` principles, loops feedback until the PR is robust, and merges to `main`.
|
||||
> **When NOT to use this:** Use `/analysis-github-issue-create` if you only want the issue (no PR/merge). Use `/local-fix` if you are the implementer.
|
||||
|
||||
---
|
||||
|
||||
## PRINCIPLES (apply throughout)
|
||||
|
||||
- **Agent-centric**: Agents, workflows, sessions, tools, memory, multi-agent safety.
|
||||
- **Protocol-driven core**: `praisonaiagents` = lightweight, protocol-first (protocols/hooks/adapters only).
|
||||
- **DRY**: identify reuse; avoid duplication.
|
||||
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
|
||||
- **Async-safe + multi-agent safe** by default.
|
||||
- **Easy for non-developers**: "Few lines of code to do the task!"
|
||||
- **Test before merge**: never merge unvalidated PRs. Always clone the branch locally and run the tests.
|
||||
- **Minimal code change**: prefer upstream one-line fixes over downstream workarounds.
|
||||
|
||||
---
|
||||
|
||||
## CANONICAL PATHS
|
||||
|
||||
```
|
||||
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
Tools: /Users/praison/PraisonAI-tools
|
||||
Docs: /Users/praison/PraisonAIDocs
|
||||
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
AGENTS.md: /Users/praison/praisonai-package/src/praisonai-agents/AGENTS.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# PART A — ANALYSIS & ISSUE (Phases 1–9)
|
||||
|
||||
These phases are identical to `/analysis-github-issue-create`. Execute them in order. **Do not implement any code** in these phases — they produce analysis and an issue only.
|
||||
|
||||
## Phase 1 — Acceptance Criteria
|
||||
|
||||
Translate the request into testable criteria:
|
||||
- API expectations (classes, methods, signatures)
|
||||
- CLI expectations (commands, flags, help text)
|
||||
- Documentation expectations (pages, examples)
|
||||
- Test expectations (unit, integration, smoke, real agentic)
|
||||
- Performance constraints (import time, hot-path)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — External Research (if applicable)
|
||||
|
||||
### 2a. Read official sources
|
||||
|
||||
If analyzing an external integration, research from primary sources:
|
||||
|
||||
```bash
|
||||
# Use read_url_content tool for:
|
||||
# - Official site / announcement pages
|
||||
# - GitHub README and docs
|
||||
# - Framework documentation
|
||||
# - Third-party write-ups
|
||||
```
|
||||
|
||||
**Capture:**
|
||||
- What is it? Purpose and design goals
|
||||
- Key terminology and concepts
|
||||
- Architecture overview
|
||||
- Current ecosystem state
|
||||
- References list (all URLs)
|
||||
|
||||
### 2b. Clone & analyze external codebase (if applicable)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> ~/<repo-name> --depth=1
|
||||
ls -la ~/<repo-name>/
|
||||
```
|
||||
|
||||
Key files to read:
|
||||
- `AGENTS.md` / `README.md` — architecture overview
|
||||
- Base class files (abstract methods, signatures)
|
||||
- Factory / registry files (registration pattern)
|
||||
- Model/config files (data structures)
|
||||
- Example implementations (minimal + full-featured)
|
||||
- `pyproject.toml` / `package.json` — dependencies
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — PraisonAI Codebase Inventory
|
||||
|
||||
### 3a. Inventory with evidence
|
||||
|
||||
Go through ALL relevant files, understand current logic end-to-end.
|
||||
|
||||
```bash
|
||||
# Core SDK
|
||||
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
|
||||
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
|
||||
|
||||
# Wrapper
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
|
||||
|
||||
# TypeScript
|
||||
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
|
||||
```
|
||||
|
||||
**Document:**
|
||||
- Public APIs: modules/classes/functions and exports
|
||||
- Protocols/adapters/hooks and extension points
|
||||
- CLI commands, options, help text
|
||||
- Integrations (db/observability/tools)
|
||||
- Tests (unit/integration), fixtures, CI config
|
||||
- Docs (Mintlify), examples, recipes
|
||||
|
||||
Provide: **file paths + key symbols + grep counts + entry points**.
|
||||
|
||||
### 3b. Identify DRY opportunities
|
||||
|
||||
- Existing abstractions that can be reused
|
||||
- Duplicated code that should be consolidated
|
||||
- Existing protocols before creating new ones
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Detailed Analysis
|
||||
|
||||
### 4a. Architecture and behavior
|
||||
|
||||
- Control flow and data flow
|
||||
- Concurrency model (sync/async)
|
||||
- Multi-agent interactions
|
||||
- Dependency boundaries (core/wrapper/tools)
|
||||
- Failure modes and error handling
|
||||
|
||||
### 4b. Invariants check
|
||||
|
||||
Verify these are preserved:
|
||||
- [ ] Protocol-driven core (no heavy impls in `praisonaiagents`)
|
||||
- [ ] Lazy imports (optional deps never at module level)
|
||||
- [ ] Backward compatible (public API changes need deprecation)
|
||||
- [ ] Safe defaults (new features opt-in)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Gap Analysis
|
||||
|
||||
Compare acceptance criteria vs current state.
|
||||
|
||||
### Checklist
|
||||
|
||||
| Area | Current State | Gap | Severity | Impact | Placement |
|
||||
|------|---------------|-----|----------|--------|-----------|
|
||||
| Core SDK | | | | | |
|
||||
| Wrapper | | | | | |
|
||||
| Tools/plugins | | | | | |
|
||||
| CLI | | | | | |
|
||||
| Docs | | | | | |
|
||||
| Tests | | | | | |
|
||||
| Exports | | | | | |
|
||||
| Perf | | | | | |
|
||||
| Multi-agent + async | | | | | |
|
||||
|
||||
### Gap severity levels
|
||||
- **Critical**: Blocks functionality
|
||||
- **High**: Significant limitation
|
||||
- **Medium**: Missing feature
|
||||
- **Low**: Nice-to-have
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Critical Review
|
||||
|
||||
### 6a. Risks and footguns
|
||||
|
||||
- API ambiguity or edge cases
|
||||
- Backward compatibility concerns
|
||||
- Coupling violations
|
||||
- Performance regressions
|
||||
- Concurrency hazards
|
||||
- Misuse risks
|
||||
- Operational concerns
|
||||
|
||||
### 6b. Mitigation strategies
|
||||
|
||||
For each risk, document:
|
||||
- "Safe by default" recommendation
|
||||
- Explicit opt-in for dangerous behavior
|
||||
- Documentation warnings
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Implementation Plan
|
||||
|
||||
Step-by-step plan (DO NOT implement):
|
||||
|
||||
### 7a. Test-first (TDD)
|
||||
|
||||
1. Unit tests for new functionality
|
||||
2. Integration tests for cross-component behavior
|
||||
3. Smoke tests for quick validation
|
||||
4. **Real agentic tests** (REQUIRED):
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="You are helpful")
|
||||
result = agent.start("Say hello in one sentence")
|
||||
print(result) # Must call LLM and produce response
|
||||
```
|
||||
|
||||
### 7b. Implementation order
|
||||
|
||||
1. Tests (TDD)
|
||||
2. Core implementation
|
||||
3. CLI integration
|
||||
4. Documentation
|
||||
5. Verification
|
||||
|
||||
### 7c. Files to change/create
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| | | |
|
||||
|
||||
### 7d. Verification commands
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pytest tests/unit/test_<feature>.py -v
|
||||
|
||||
# Integration tests
|
||||
pytest tests/integration/test_<feature>.py -v
|
||||
|
||||
# Smoke test
|
||||
python -c "from praisonaiagents import <Feature>; print('OK')"
|
||||
|
||||
# Real agentic test
|
||||
python examples/<feature>_example.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Draft GitHub Issue
|
||||
|
||||
The issue must be **self-contained**. Another agent must be able to implement by reading only the issue.
|
||||
|
||||
### Required sections
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
One paragraph: what, why, and the end goal.
|
||||
|
||||
## Background
|
||||
- Purpose and design goals
|
||||
- Why this is valuable
|
||||
- Current state / ecosystem
|
||||
|
||||
## Architecture Analysis
|
||||
### Current Implementation
|
||||
- Key files and their purposes
|
||||
- Integration points
|
||||
- Data flow
|
||||
|
||||
### Key File Locations
|
||||
| File | Purpose | Lines |
|
||||
|------|---------|-------|
|
||||
|
||||
## Gap Analysis Summary
|
||||
### Critical Gaps
|
||||
| Gap | Impact | Effort |
|
||||
|-----|--------|--------|
|
||||
|
||||
### Feature Gaps
|
||||
| Feature | Current Support | Gap |
|
||||
|---------|-----------------|-----|
|
||||
|
||||
## Proposed Implementation
|
||||
### Phase 1: Minimal
|
||||
Working code example.
|
||||
|
||||
### Phase 2: Production
|
||||
Working code example.
|
||||
|
||||
## Files to Create / Modify
|
||||
### New files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
|
||||
### Modified files
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
|
||||
## Technical Considerations
|
||||
- Dependencies
|
||||
- Performance impact
|
||||
- Safety / approval
|
||||
- Multi-agent safety
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Verifiable checkbox list
|
||||
|
||||
## Implementation Notes
|
||||
### Key Files to Read First
|
||||
1. `path/to/file.py` (N lines) — purpose
|
||||
|
||||
### Critical Integration Points
|
||||
1. Where X connects to Y
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Copy-pastable commands
|
||||
```
|
||||
|
||||
## References
|
||||
- [Link](url)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Create GitHub Issue
|
||||
|
||||
### 9a. Create via `gh` CLI
|
||||
|
||||
```bash
|
||||
gh issue create \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--title "<Type>: <Descriptive Title>" \
|
||||
--body-file /tmp/issue-body.md
|
||||
```
|
||||
|
||||
**Title formats:**
|
||||
- `Feature: <What>` — new functionality
|
||||
- `Integration: <External System>` — external integration
|
||||
- `Fix: <Problem>` — bug fix proposal
|
||||
|
||||
### 9b. Record the issue number
|
||||
|
||||
```bash
|
||||
ISSUE_NUM=$(gh issue view <number> --repo MervinPraison/PraisonAI --json number --jq '.number')
|
||||
echo "Tracking issue #$ISSUE_NUM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# PART B — IMPLEMENTATION MONITORING (Phases 10–15)
|
||||
|
||||
After the issue is filed, other agents (`@claude`, GitHub Copilot agent, CodeRabbit) pick it up automatically. This part watches, shepherds, validates, and merges.
|
||||
|
||||
## Phase 10 — Wait ~10 min for implementing agents
|
||||
|
||||
After `gh issue create`, Claude and Copilot typically start work within seconds and finish a first pass in 5–15 minutes.
|
||||
|
||||
```bash
|
||||
echo "Waiting 10 minutes for implementing agents..."
|
||||
echo "Start: $(date)"
|
||||
sleep 600
|
||||
echo "End: $(date)"
|
||||
```
|
||||
|
||||
### Then check: did they open a PR?
|
||||
|
||||
```bash
|
||||
REPO=MervinPraison/PraisonAI
|
||||
ISSUE_NUM=<number>
|
||||
|
||||
# Check for PRs that reference the issue
|
||||
gh pr list --repo $REPO --state open --search "fixes #$ISSUE_NUM OR closes #$ISSUE_NUM" --json number,title,headRefName
|
||||
|
||||
# Also check for branches matching claude/issue-<N>-* naming convention
|
||||
gh api repos/$REPO/branches --paginate --jq ".[] | select(.name | startswith(\"claude/issue-$ISSUE_NUM\")) | .name"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Create PR if none exists
|
||||
|
||||
If Claude pushed a branch but did not open a PR, open one yourself.
|
||||
|
||||
```bash
|
||||
REPO=MervinPraison/PraisonAI
|
||||
ISSUE_NUM=<number>
|
||||
BRANCH=$(gh api repos/$REPO/branches --paginate --jq ".[] | select(.name | startswith(\"claude/issue-$ISSUE_NUM\")) | .name" | head -1)
|
||||
|
||||
if [ -n "$BRANCH" ]; then
|
||||
TITLE=$(gh api repos/$REPO/commits/$BRANCH --jq '.commit.message' | head -1)
|
||||
gh pr create --repo $REPO --head "$BRANCH" --base main \
|
||||
--title "$TITLE" \
|
||||
--body "Automated PR from Claude's work on issue #$ISSUE_NUM.
|
||||
|
||||
Branch: \`$BRANCH\`
|
||||
|
||||
Closes #$ISSUE_NUM"
|
||||
fi
|
||||
```
|
||||
|
||||
### If no branch exists after 10 minutes
|
||||
|
||||
Re-trigger Claude by commenting on the issue, then wait another 10 minutes and re-check:
|
||||
|
||||
```bash
|
||||
gh issue comment $ISSUE_NUM --repo $REPO \
|
||||
--body "@claude please pick up this issue — full analysis and plan are in the issue body."
|
||||
sleep 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Wait ~10 min for reviewing agents
|
||||
|
||||
Once a PR exists, `CodeRabbit`, `Copilot`, and CI pipelines run. Give them time to complete.
|
||||
|
||||
```bash
|
||||
echo "Waiting 10 minutes for PR reviewers and CI..."
|
||||
sleep 600
|
||||
```
|
||||
|
||||
### Collect status
|
||||
|
||||
```bash
|
||||
PR=<pr-number>
|
||||
gh pr view $PR --repo $REPO --json state,mergeable,mergeStateStatus,updatedAt,commits,files \
|
||||
| python -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(f\" State: {d['state']} Mergeable: {d['mergeable']} MergeState: {d['mergeStateStatus']}\")
|
||||
print(f\" Commits: {len(d['commits'])}\")
|
||||
for f in d['files']:
|
||||
print(f\" {f['path']}: +{f['additions']} -{f['deletions']}\")
|
||||
"
|
||||
|
||||
# CI & review checks
|
||||
gh pr view $PR --repo $REPO --json statusCheckRollup > /tmp/pr-checks.json
|
||||
python -c "
|
||||
import json
|
||||
for c in json.load(open('/tmp/pr-checks.json'))['statusCheckRollup']:
|
||||
s = c.get('conclusion') or c.get('status','?')
|
||||
name = c.get('name') or c.get('context','?')
|
||||
if s and s not in ('SKIPPED',):
|
||||
print(f' {name}: {s}')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — Validate the PR locally
|
||||
|
||||
**Never merge an unvalidated PR.** Always clone the branch and run tests.
|
||||
|
||||
```bash
|
||||
REPO_URL=https://github.com/MervinPraison/PraisonAI.git
|
||||
BRANCH=<branch-name>
|
||||
WORKDIR=/tmp/pr-validate
|
||||
|
||||
rm -rf $WORKDIR
|
||||
git clone --depth 1 --branch "$BRANCH" $REPO_URL $WORKDIR
|
||||
cd $WORKDIR
|
||||
|
||||
# Files touched by the PR
|
||||
gh pr view $PR --repo $REPO --json files --jq '.files[].path'
|
||||
|
||||
# Run the tests for changed files — ALWAYS use timeout (user global rule)
|
||||
timeout 120 python -m pytest <test-paths> -v --tb=short
|
||||
|
||||
# Critical invariant checks (adapt per feature):
|
||||
# 1) Lazy-import: heavy deps never imported at module level
|
||||
timeout 30 python - <<'PY'
|
||||
import sys
|
||||
from praisonai.observability import <NewThing> # example
|
||||
assert 'heavy_dep' not in sys.modules, "eager import — violates AGENTS.md §4.2"
|
||||
print("OK: lazy import preserved")
|
||||
PY
|
||||
|
||||
# 2) Cold-import benchmark (no regression)
|
||||
timeout 30 python -c "import time; t=time.time(); import praisonaiagents; print(f'{(time.time()-t)*1000:.1f} ms')"
|
||||
```
|
||||
|
||||
### Mergeability gate
|
||||
|
||||
A PR is **ready to merge only if ALL of these are true**:
|
||||
|
||||
- [ ] `gh pr view` reports `mergeable=MERGEABLE` and `mergeStateStatus` in `CLEAN`/`UNSTABLE` (not `DIRTY`/`BLOCKED`).
|
||||
- [ ] All `test (3.x)` matrix jobs report `SUCCESS`.
|
||||
- [ ] Local `pytest` on the changed test files passes with **zero failures** (skipped/xfailed tests are OK).
|
||||
- [ ] Lazy-import invariant preserved (if the feature adds an optional dep).
|
||||
- [ ] Cold-import time within ±2 % of `main` (AGENTS.md §4.2).
|
||||
- [ ] No files outside the issue's declared scope were changed.
|
||||
- [ ] The diff respects all invariants in AGENTS.md §4.6.
|
||||
|
||||
If **any** is false → go to Phase 14 (feedback). Do not merge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — Feedback to `@claude` when issues found
|
||||
|
||||
When validation fails, post a precise, actionable comment tagging `@claude`, referencing the exact test name, error, file, and the AGENTS.md principle violated.
|
||||
|
||||
```bash
|
||||
gh pr comment $PR --repo $REPO --body "$(cat <<'EOF'
|
||||
Hold on merge: validated locally on HEAD `<short-sha>` and found these issues:
|
||||
|
||||
**Test failures:**
|
||||
```
|
||||
FAILED tests/.../test_x.py::TestY::test_z
|
||||
AssertionError: <actual-message>
|
||||
```
|
||||
|
||||
**Root cause:** <one-line diagnosis referencing the exact file and line>.
|
||||
|
||||
**AGENTS.md violation(s):**
|
||||
- §<section>: <principle>
|
||||
|
||||
**Requested fix (minimal):**
|
||||
- Change `<file>:<line>` to `<what>`.
|
||||
- Or update the test assertion to `<what>` (if the test is wrong, not the code).
|
||||
|
||||
**What already passes:**
|
||||
- N/M tests pass.
|
||||
- Lazy-import invariant preserved ✅ (or not — state explicitly).
|
||||
|
||||
@claude please push a fix. Follow AGENTS.md principles: minimal change, protocol-driven, no perf impact, lazy imports, backward-compatible, safe defaults. Make sure the PR is robust — run the tests locally before force-pushing.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Loop
|
||||
|
||||
After commenting:
|
||||
|
||||
```bash
|
||||
echo "Waiting 10 minutes for Claude to push fixes..."
|
||||
sleep 600
|
||||
# → return to Phase 12 (fetch new commits, re-run checks)
|
||||
```
|
||||
|
||||
Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully green, or escalate to a human after 3 failed rounds.
|
||||
|
||||
### When the PR has merge conflicts
|
||||
|
||||
If `mergeStateStatus=DIRTY`, ask Claude to rebase rather than resolving conflicts yourself on non-trivial logic:
|
||||
|
||||
```bash
|
||||
gh pr comment $PR --repo $REPO --body "@claude this PR has merge conflicts with \`main\` (PR #<X> was merged, touching the same regions). Please rebase onto latest \`main\`, re-run \`pytest <paths>\`, and force-push."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 15 — Merge to `main`
|
||||
|
||||
Once Phase 13's gate is fully green:
|
||||
|
||||
### 15a. Final diff review against AGENTS.md
|
||||
|
||||
```bash
|
||||
gh pr diff $PR --repo $REPO | less
|
||||
```
|
||||
|
||||
**Hard gate — every box must be ticked before merge:**
|
||||
|
||||
- [ ] **Protocol-driven core (§4.1)**: no heavy implementations leaked into `praisonaiagents`.
|
||||
- [ ] **No perf impact (§4.2)**: lazy imports confirmed; import benchmark within ±2 %.
|
||||
- [ ] **DRY (§4.3)**: no new duplication; existing protocols/adapters reused where possible.
|
||||
- [ ] **Agent-centric (§4.4)**: API centers on agents/sessions/tools/memory.
|
||||
- [ ] **Async-safe + multi-agent safe (§4.5)**: no shared mutable globals; locks where needed.
|
||||
- [ ] **Invariants preserved (§4.6)**: backward compatible, safe defaults, deterministic tests.
|
||||
- [ ] **Naming conventions (§4.9)**: `XProtocol` for interfaces, `XAdapter` for impls, `add_*`/`get_*`/`save`/`load` verbs.
|
||||
- [ ] No `.md` files added beyond those explicitly requested (per user global rule).
|
||||
- [ ] Tests run locally with `timeout` — never hang (per user global rule).
|
||||
|
||||
### 15b. Merge
|
||||
|
||||
```bash
|
||||
gh pr merge $PR --repo $REPO --squash --delete-branch
|
||||
# --squash preserves linear main; --delete-branch keeps the repo clean
|
||||
```
|
||||
|
||||
### 15c. Verify
|
||||
|
||||
```bash
|
||||
gh pr view $PR --repo $REPO --json state,mergedAt,mergeCommit
|
||||
gh issue view $ISSUE_NUM --repo $REPO --json state # should auto-close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Orchestration Snippet (one-shot reference)
|
||||
|
||||
For a single end-to-end invocation, chain Part B phases:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO=MervinPraison/PraisonAI
|
||||
ISSUE_NUM=<fill>
|
||||
|
||||
# Phase 10: wait for implementing agents
|
||||
sleep 600
|
||||
|
||||
# Phase 11: ensure PR exists
|
||||
BRANCH=$(gh api repos/$REPO/branches --paginate \
|
||||
--jq ".[] | select(.name | startswith(\"claude/issue-$ISSUE_NUM\")) | .name" | head -1)
|
||||
PR=$(gh pr list --repo $REPO --state open --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
|
||||
if [ -z "$PR" ] && [ -n "$BRANCH" ]; then
|
||||
TITLE=$(gh api repos/$REPO/commits/$BRANCH --jq '.commit.message' | head -1)
|
||||
gh pr create --repo $REPO --head "$BRANCH" --base main \
|
||||
--title "$TITLE" --body "Closes #$ISSUE_NUM"
|
||||
PR=$(gh pr list --repo $REPO --state open --head "$BRANCH" --json number --jq '.[0].number')
|
||||
fi
|
||||
echo "Tracking PR #$PR"
|
||||
|
||||
# Phase 12: wait for reviewers + CI
|
||||
sleep 600
|
||||
|
||||
# Phases 13–14: validate + feedback loop (cap at 3 rounds)
|
||||
for round in 1 2 3; do
|
||||
echo "=== Validation round $round ==="
|
||||
# clone + pytest (per feature) — if green break; else gh pr comment + sleep 600
|
||||
break # placeholder
|
||||
done
|
||||
|
||||
# Phase 15: merge
|
||||
gh pr merge $PR --repo $REPO --squash --delete-branch
|
||||
gh issue view $ISSUE_NUM --repo $REPO --json state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist — Before Merging
|
||||
|
||||
- [ ] Issue exists and describes the work
|
||||
- [ ] PR exists and references the issue (`Closes #N`)
|
||||
- [ ] Branch was fetched and tests were run **locally** — not just CI-trusted
|
||||
- [ ] All PR-relevant tests pass with zero failures
|
||||
- [ ] CI matrix (3.10 / 3.11 / 3.12) is all green
|
||||
- [ ] Lazy-import / cold-import invariants verified if optional deps added
|
||||
- [ ] Diff reviewed against every AGENTS.md §4 principle
|
||||
- [ ] `mergeStateStatus` in `CLEAN`/`UNSTABLE`, never `DIRTY`/`BLOCKED`
|
||||
- [ ] No files outside the issue's scope were changed
|
||||
- [ ] If feedback was given, `@claude` had a chance to respond and fixed everything
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---------|-----|
|
||||
| Merging without running tests locally | Always `git clone` the branch and run `pytest` |
|
||||
| Resolving complex conflicts manually | Ask `@claude` to rebase instead |
|
||||
| Trusting CI alone | CI jobs can be skipped / out-of-date; local validation is source of truth |
|
||||
| Vague feedback to `@claude` | Every comment must cite file, line, test name, error, and AGENTS.md section |
|
||||
| Waiting forever | Cap loops at 3 rounds, then escalate to a human |
|
||||
| Running commands without `timeout` | Always `timeout 60 ...` per user global rule |
|
||||
| Creating `.md` docs proactively | Don't — user rule forbids unrequested `.md` files |
|
||||
| Skipping diff review | Every merge needs a full-diff AGENTS.md pass |
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. **Never merge without local test validation.**
|
||||
2. **Never merge when `mergeStateStatus = DIRTY` or `BLOCKED`.**
|
||||
3. **Every `@claude` comment must be actionable** — file, line, exact fix, AGENTS.md ref.
|
||||
4. **Cap feedback loops at 3 rounds** — escalate after that, don't spin forever.
|
||||
5. **Use `--squash --delete-branch`** to keep `main` linear and the repo clean.
|
||||
6. **Always verify** the issue auto-closed after merge.
|
||||
7. **All `run_command` calls use `timeout`** to avoid hangs (user global rule).
|
||||
8. **No proactive `.md` creation** beyond this workflow file (user global rule).
|
||||
@@ -202,6 +202,13 @@ _LAZY_IMPORTS = {
|
||||
'FlowDisplay': ('praisonaiagents.flow_display', 'FlowDisplay'),
|
||||
'track_workflow': ('praisonaiagents.flow_display', 'track_workflow'),
|
||||
|
||||
# CLI Backend Protocol (Protocol-driven external CLI integration)
|
||||
'CliBackendProtocol': ('praisonaiagents.cli_backend.protocols', 'CliBackendProtocol'),
|
||||
'CliBackendConfig': ('praisonaiagents.cli_backend.protocols', 'CliBackendConfig'),
|
||||
'CliSessionBinding': ('praisonaiagents.cli_backend.protocols', 'CliSessionBinding'),
|
||||
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
|
||||
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
|
||||
@@ -212,6 +219,7 @@ _LAZY_IMPORTS = {
|
||||
'LLMError': ('praisonaiagents.errors', 'LLMError'),
|
||||
'ValidationError': ('praisonaiagents.errors', 'ValidationError'),
|
||||
'NetworkError': ('praisonaiagents.errors', 'NetworkError'),
|
||||
'PraisonAIConfigError': ('praisonaiagents.errors', 'PraisonAIConfigError'),
|
||||
'ErrorContextProtocol': ('praisonaiagents.errors', 'ErrorContextProtocol'),
|
||||
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
|
||||
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
|
||||
|
||||
@@ -33,6 +33,10 @@ def __getattr__(name):
|
||||
from .heartbeat import HeartbeatConfig
|
||||
_lazy_cache[name] = HeartbeatConfig
|
||||
return HeartbeatConfig
|
||||
if name == 'InterruptController':
|
||||
from .interrupt import InterruptController
|
||||
_lazy_cache[name] = InterruptController
|
||||
return InterruptController
|
||||
|
||||
# Specialized agents - lazy loaded (import rich)
|
||||
if name == 'ImageAgent':
|
||||
@@ -194,6 +198,7 @@ __all__ = [
|
||||
'BudgetExceededError',
|
||||
'Heartbeat',
|
||||
'HeartbeatConfig',
|
||||
'InterruptController',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
|
||||
@@ -20,6 +20,7 @@ from .tool_execution import ToolExecutionMixin
|
||||
from .chat_handler import ChatHandlerMixin
|
||||
from .session_manager import SessionManagerMixin
|
||||
from .async_safety import AsyncSafeState
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
|
||||
# Module-level logger for thread safety errors and debugging
|
||||
logger = get_logger(__name__)
|
||||
@@ -169,11 +170,72 @@ from ..config.feature_configs import (
|
||||
# Applied even when context management is disabled to prevent runaway tool outputs
|
||||
DEFAULT_TOOL_OUTPUT_LIMIT = 16000
|
||||
|
||||
# Global variables for API server (protected by _server_lock for thread safety)
|
||||
_server_lock = threading.Lock()
|
||||
_server_started = {} # Dict of port -> started boolean
|
||||
_registered_agents = {} # Dict of port -> Dict of path -> agent_id
|
||||
_shared_apps = {} # Dict of port -> FastAPI app
|
||||
class ServerRegistry:
|
||||
"""Registry for API server state per-port."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._server_started = {} # Dict of port -> started boolean
|
||||
self._registered_agents = {} # Dict of port -> Dict of path -> agent_id
|
||||
self._shared_apps = {} # Dict of port -> FastAPI app
|
||||
|
||||
# Class-level lock for thread-safe singleton creation
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def get_default_instance():
|
||||
"""Get default global registry for backward compatibility."""
|
||||
if not hasattr(ServerRegistry, '_default_instance'):
|
||||
# Double-checked locking pattern with shared class-level lock
|
||||
with ServerRegistry._instance_lock:
|
||||
if not hasattr(ServerRegistry, '_default_instance'):
|
||||
ServerRegistry._default_instance = ServerRegistry()
|
||||
return ServerRegistry._default_instance
|
||||
|
||||
def is_server_started(self, port: int) -> bool:
|
||||
with self._lock:
|
||||
return self._server_started.get(port, False)
|
||||
|
||||
def set_server_started(self, port: int, started: bool) -> None:
|
||||
with self._lock:
|
||||
self._server_started[port] = started
|
||||
|
||||
def get_shared_app(self, port: int):
|
||||
with self._lock:
|
||||
return self._shared_apps.get(port)
|
||||
|
||||
def set_shared_app(self, port: int, app) -> None:
|
||||
with self._lock:
|
||||
self._shared_apps[port] = app
|
||||
|
||||
def register_agent(self, port: int, path: str, agent_id: str) -> None:
|
||||
with self._lock:
|
||||
if port not in self._registered_agents:
|
||||
self._registered_agents[port] = {}
|
||||
self._registered_agents[port][path] = agent_id
|
||||
|
||||
def get_registered_agents(self, port: int) -> dict:
|
||||
with self._lock:
|
||||
return self._registered_agents.get(port, {}).copy()
|
||||
|
||||
def cleanup_agent_registrations(self, agent_id: str) -> None:
|
||||
"""Remove all registrations for an agent ID and clean empty port state."""
|
||||
with self._lock:
|
||||
ports_to_clean = []
|
||||
for port, path_dict in self._registered_agents.items():
|
||||
paths_to_remove = [path for path, registered_id in path_dict.items() if registered_id == agent_id]
|
||||
for path in paths_to_remove:
|
||||
del path_dict[path]
|
||||
if not path_dict:
|
||||
ports_to_clean.append(port)
|
||||
|
||||
for port in ports_to_clean:
|
||||
self._registered_agents.pop(port, None)
|
||||
self._server_started.pop(port, None)
|
||||
|
||||
# Backward compatibility - use default instance
|
||||
def _get_default_server_registry() -> ServerRegistry:
|
||||
return ServerRegistry.get_default_instance()
|
||||
|
||||
# Don't import FastAPI dependencies here - use lazy loading instead
|
||||
|
||||
@@ -183,6 +245,7 @@ if TYPE_CHECKING:
|
||||
from ..context.models import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
from .interrupt import InterruptController
|
||||
from ..agent.autonomy import AutonomyConfig
|
||||
from ..task.task import Task
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
@@ -192,7 +255,7 @@ if TYPE_CHECKING:
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
_agent_counter_lock = threading.Lock()
|
||||
@@ -469,7 +532,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
|
||||
# Each follows: False=disabled, True=defaults, Config=custom
|
||||
# ============================================================
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', Any]] = None,
|
||||
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
|
||||
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
|
||||
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
|
||||
@@ -486,8 +549,11 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
|
||||
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
|
||||
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
|
||||
parallel_tool_calls: bool = False, # Gap 2: Enable parallel execution of batched LLM tool calls
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -512,7 +578,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
memory: Memory system configuration. Accepts:
|
||||
- bool: True enables defaults, False disables
|
||||
- MemoryConfig: Custom configuration
|
||||
- MemoryManager: Pre-configured instance
|
||||
- Any: Pre-configured memory instance
|
||||
knowledge: Knowledge sources. Accepts:
|
||||
- bool: True enables defaults
|
||||
- List[str]: File paths, URLs, or text content
|
||||
@@ -575,11 +641,22 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
- LearnConfig: Custom configuration
|
||||
Learning is a first-class citizen, peer to memory. It captures patterns,
|
||||
preferences, and insights from interactions to improve future responses.
|
||||
parallel_tool_calls: Enable parallel execution of batched LLM tool calls (default False).
|
||||
When True and LLM returns multiple tool calls in a single response, they execute
|
||||
concurrently instead of sequentially. Provides ~3x speedup for I/O-bound tools.
|
||||
Maintains backward compatibility with False default.
|
||||
backend: External managed agent backend for hybrid execution. Accepts:
|
||||
- ManagedAgentIntegration: External managed agent service
|
||||
- None: Use local execution (default)
|
||||
When provided, agent can delegate execution to managed infrastructure
|
||||
for long-running tasks or when local resources are constrained.
|
||||
cli_backend: CLI backend for delegating full turns to external CLI tools. Accepts:
|
||||
- str: Backend ID ("claude-code", "codex-cli", "gemini-cli")
|
||||
- CliBackendProtocol: Custom CLI backend instance
|
||||
- None: Use standard LLM execution (default)
|
||||
When provided, agent delegates entire conversation turns to the CLI tool
|
||||
instead of using the built-in LLM. Enables session continuity and
|
||||
tool integration through external AI coding assistants.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -709,6 +786,8 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
alternative="use 'execution=ExecutionConfig(rate_limiter=obj)' instead",
|
||||
stacklevel=3
|
||||
)
|
||||
# Note: parallel_tool_calls is NOT deprecated - it's a new Gap 2 feature
|
||||
# Both direct parameter and ExecutionConfig.parallel_tool_calls are supported
|
||||
if verification_hooks is not None:
|
||||
warn_deprecated_param(
|
||||
"verification_hooks",
|
||||
@@ -884,6 +963,8 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
allow_code_execution = True
|
||||
if _exec_config.code_mode != "safe":
|
||||
code_execution_mode = _exec_config.code_mode
|
||||
# Get parallel_tool_calls from ExecutionConfig, fall back to parameter
|
||||
parallel_tool_calls = getattr(_exec_config, 'parallel_tool_calls', parallel_tool_calls)
|
||||
# Budget guard extraction
|
||||
_max_budget = getattr(_exec_config, 'max_budget', None)
|
||||
_on_budget_exceeded = getattr(_exec_config, 'on_budget_exceeded', 'stop') or 'stop'
|
||||
@@ -891,6 +972,8 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
max_iter, max_rpm, max_execution_time, max_retry_limit = 20, None, None, 2
|
||||
_max_budget = None
|
||||
_on_budget_exceeded = 'stop'
|
||||
# Keep parallel_tool_calls parameter value when no ExecutionConfig provided
|
||||
# (already set from parameter, no need to override)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve TEMPLATES param - FAST PATH
|
||||
@@ -981,7 +1064,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
if skills is None:
|
||||
_skills_config = None
|
||||
elif isinstance(skills, list):
|
||||
_skills_config = SkillsConfig(sources=skills)
|
||||
_skills_config = SkillsConfig(paths=skills)
|
||||
elif isinstance(skills, SkillsConfig):
|
||||
_skills_config = skills
|
||||
else:
|
||||
@@ -993,10 +1076,12 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
string_mode="path_as_source",
|
||||
default=None,
|
||||
)
|
||||
_skills_auto_discover = False
|
||||
if _skills_config is not None:
|
||||
if isinstance(_skills_config, SkillsConfig):
|
||||
_skills = _skills_config.paths
|
||||
skills_dirs = _skills_config.dirs
|
||||
_skills_auto_discover = bool(_skills_config.auto_discover)
|
||||
elif isinstance(_skills_config, list):
|
||||
_skills = _skills_config
|
||||
|
||||
@@ -1042,18 +1127,18 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
if _memory_config.auto_save is not None:
|
||||
auto_save = _memory_config.auto_save
|
||||
# Convert to internal format
|
||||
backend = _memory_config.backend
|
||||
if hasattr(backend, 'value'):
|
||||
backend = backend.value
|
||||
memory_backend = _memory_config.backend
|
||||
if hasattr(memory_backend, 'value'):
|
||||
memory_backend = memory_backend.value
|
||||
# If learn is enabled, pass as dict to trigger full Memory class
|
||||
if _memory_config.learn:
|
||||
memory = _memory_config.to_dict()
|
||||
elif backend == "file":
|
||||
elif memory_backend == "file":
|
||||
memory = True
|
||||
elif _memory_config.config:
|
||||
memory = _memory_config.config
|
||||
else:
|
||||
memory = backend
|
||||
memory = memory_backend
|
||||
elif hasattr(_memory_config, 'search') and hasattr(_memory_config, 'add'):
|
||||
# Memory instance - pass through
|
||||
pass
|
||||
@@ -1381,6 +1466,10 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
self.self_reflect = True if self_reflect is None else self_reflect
|
||||
|
||||
self.instructions = instructions
|
||||
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
|
||||
self.parallel_tool_calls = parallel_tool_calls
|
||||
# G2: Store interrupt controller for cooperative cancellation
|
||||
self.interrupt_controller = interrupt_controller
|
||||
# Check for model name in environment variable if not provided
|
||||
self._using_custom_llm = False
|
||||
# Flag to track if final result has been displayed to prevent duplicates
|
||||
@@ -1563,6 +1652,8 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
# Token budget guard (zero overhead when _max_budget is None)
|
||||
self._max_budget = _max_budget
|
||||
self._on_budget_exceeded = _on_budget_exceeded
|
||||
# Thread-safe cost/token tracking (Gap 1a fix)
|
||||
self._cost_lock = threading.Lock()
|
||||
self._total_cost = 0.0
|
||||
self._total_tokens_in = 0
|
||||
self._total_tokens_out = 0
|
||||
@@ -1614,6 +1705,9 @@ Your Goal: {self.goal}
|
||||
self.prompt_caching = prompt_caching
|
||||
self.claude_memory = claude_memory
|
||||
|
||||
# Initialize closure flag for GC cleanup
|
||||
self._closed = False
|
||||
|
||||
# Session management
|
||||
self.auto_save = auto_save # Session name for auto-saving
|
||||
|
||||
@@ -1677,6 +1771,31 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = None
|
||||
self._approve_all_tools = False
|
||||
self._approval_timeout = 0
|
||||
# No explicit approval kwarg — honour PRAISONAI_TOOL_SAFETY.
|
||||
# Default preset "default" blocks only destructive ops
|
||||
# (delete_*, execute_command, execute_code, kill_process, move/copy)
|
||||
# while leaving read / create / edit tools fully auto-approved.
|
||||
# Users who want the pre-4.6.27 "trust everything" behaviour
|
||||
# export PRAISONAI_TOOL_SAFETY=off. This adds zero Agent kwargs.
|
||||
_raw_safety_env = os.environ.get("PRAISONAI_TOOL_SAFETY")
|
||||
_safety_env = (_raw_safety_env or "").strip().lower()
|
||||
if _safety_env not in ("off", "full", "none", "0", "false"):
|
||||
from ..approval.registry import PERMISSION_PRESETS
|
||||
_resolved_safety_env = _safety_env or "default"
|
||||
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
|
||||
if _preset_deny is None and _safety_env:
|
||||
# Unknown env value - fall back to safe default and log warning
|
||||
# (logging is already imported at module level; a local `import logging`
|
||||
# here would shadow it and cause UnboundLocalError on earlier uses
|
||||
# inside __init__ such as the custom-LLM tools branch.)
|
||||
logging.getLogger(__name__).warning(
|
||||
"Unknown PRAISONAI_TOOL_SAFETY value %r; falling back to 'default' preset.",
|
||||
_raw_safety_env,
|
||||
)
|
||||
_resolved_safety_env = "default"
|
||||
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
|
||||
if _preset_deny is not None:
|
||||
self._perm_deny = _preset_deny
|
||||
elif isinstance(approval, ApprovalConfig):
|
||||
self._approval_backend = approval.backend
|
||||
self._approve_all_tools = approval.all_tools
|
||||
@@ -1702,6 +1821,7 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = AutoApproveBackend()
|
||||
# Pending approvals for async (non-blocking) mode
|
||||
self._pending_approvals = {}
|
||||
self._approvals_lock = asyncio.Lock()
|
||||
|
||||
# P8/G11: Tool timeout - prevent slow tools from blocking
|
||||
self._tool_timeout = tool_timeout
|
||||
@@ -1756,6 +1876,7 @@ Your Goal: {self.goal}
|
||||
# Agent Skills configuration (lazy loaded for zero performance impact)
|
||||
self._skills = _skills
|
||||
self._skills_dirs = skills_dirs
|
||||
self._skills_auto_discover = _skills_auto_discover
|
||||
self._skill_manager = None # Lazy loaded
|
||||
self._skills_initialized = False
|
||||
|
||||
@@ -1806,6 +1927,11 @@ Your Goal: {self.goal}
|
||||
|
||||
# Backend - external managed agent backend for hybrid execution
|
||||
self.backend = backend
|
||||
|
||||
# CLI Backend - external CLI backend for delegating full turns
|
||||
self._cli_backend = None
|
||||
if cli_backend is not None:
|
||||
self._cli_backend = self._resolve_cli_backend(cli_backend)
|
||||
|
||||
# Telemetry - lazy initialized via property for performance
|
||||
self.__telemetry = None
|
||||
@@ -1830,13 +1956,29 @@ Your Goal: {self.goal}
|
||||
|
||||
@chat_history.setter
|
||||
def chat_history(self, value):
|
||||
"""Set chat history (updates the underlying async-safe state)."""
|
||||
self.__chat_history_state.value = value
|
||||
"""Set chat history (updates the underlying async-safe state with lock)."""
|
||||
with self.__chat_history_state.lock():
|
||||
self.__chat_history_state.value = value
|
||||
|
||||
@property
|
||||
def _history_lock(self):
|
||||
"""Get appropriate lock for chat history based on execution context."""
|
||||
return self.__chat_history_state
|
||||
|
||||
def _append_to_chat_history(self, message: dict):
|
||||
"""Thread-safe append to chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value.append(message)
|
||||
|
||||
def _truncate_chat_history(self, length: int):
|
||||
"""Thread-safe truncation of chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value[:] = self._history_lock.value[:length]
|
||||
|
||||
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
|
||||
"""Thread-safe replacement of entire chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value[:] = new_history
|
||||
|
||||
@property
|
||||
def _cache_lock(self):
|
||||
@@ -1906,7 +2048,9 @@ Your Goal: {self.goal}
|
||||
@property
|
||||
def total_cost(self) -> float:
|
||||
"""Cumulative USD cost of all LLM calls in this agent run."""
|
||||
return self._total_cost
|
||||
# Thread-safe cost reading (Gap 1a fix)
|
||||
with self._cost_lock:
|
||||
return self._total_cost
|
||||
|
||||
@property
|
||||
def cost_summary(self) -> dict:
|
||||
@@ -1915,12 +2059,14 @@ Your Goal: {self.goal}
|
||||
Returns:
|
||||
dict with keys: tokens_in, tokens_out, cost, llm_calls
|
||||
"""
|
||||
return {
|
||||
"tokens_in": self._total_tokens_in,
|
||||
"tokens_out": self._total_tokens_out,
|
||||
"cost": self._total_cost,
|
||||
"llm_calls": self._llm_call_count,
|
||||
}
|
||||
# Thread-safe cost reading (Gap 1a fix)
|
||||
with self._cost_lock:
|
||||
return {
|
||||
"tokens_in": self._total_tokens_in,
|
||||
"tokens_out": self._total_tokens_out,
|
||||
"cost": self._total_cost,
|
||||
"llm_calls": self._llm_call_count,
|
||||
}
|
||||
|
||||
@property
|
||||
def context_manager(self) -> Optional[Any]:
|
||||
@@ -2138,31 +2284,52 @@ Summary:"""
|
||||
@property
|
||||
def skill_manager(self) -> Optional[Any]:
|
||||
"""Lazily initialize SkillManager only when skills are accessed."""
|
||||
if self._skill_manager is None and (self._skills or self._skills_dirs):
|
||||
auto_discover = bool(getattr(self, "_skills_auto_discover", False))
|
||||
should_init = self._skill_manager is None and (
|
||||
self._skills or self._skills_dirs or auto_discover
|
||||
)
|
||||
if should_init:
|
||||
from ..skills import SkillManager
|
||||
self._skill_manager = SkillManager()
|
||||
|
||||
|
||||
# Add explicit skill paths
|
||||
if self._skills:
|
||||
for skill_path in self._skills:
|
||||
self._skill_manager.add_skill(skill_path)
|
||||
|
||||
# Discover skills from directories
|
||||
|
||||
# Discover skills from directories; honour SkillsConfig.auto_discover
|
||||
# by falling back to default locations when requested.
|
||||
if self._skills_dirs:
|
||||
self._skill_manager.discover(self._skills_dirs, include_defaults=False)
|
||||
|
||||
self._skill_manager.discover(
|
||||
self._skills_dirs,
|
||||
include_defaults=auto_discover,
|
||||
)
|
||||
elif auto_discover:
|
||||
self._skill_manager.discover(include_defaults=True)
|
||||
|
||||
self._skills_initialized = True
|
||||
|
||||
|
||||
# Auto-add skill execution tools if not already present
|
||||
self._add_skill_tools()
|
||||
return self._skill_manager
|
||||
|
||||
def _add_skill_tools(self):
|
||||
"""Add tools required for skill execution (read_file, run_skill_script).
|
||||
|
||||
|
||||
Uses lazy imports from praisonaiagents.tools to avoid performance impact
|
||||
when skills are not used.
|
||||
|
||||
G-E fix: run_skill_script is now safer by default:
|
||||
- PRAISONAI_DISABLE_SKILL_TOOLS=1: disables all skill tools (explicit deny wins)
|
||||
- PRAISONAI_ENABLE_SKILL_TOOLS=1: enables run_skill_script by default
|
||||
- Any loaded skill with 'run_skill_script' in allowed-tools: enables it
|
||||
- Otherwise: only read_file is added (safer default)
|
||||
"""
|
||||
import os as _os
|
||||
if _os.environ.get("PRAISONAI_DISABLE_SKILL_TOOLS") in ("1", "true", "True"):
|
||||
logging.info("Skill helper tools disabled via PRAISONAI_DISABLE_SKILL_TOOLS")
|
||||
return
|
||||
|
||||
# Check if tools already include required capabilities
|
||||
tool_names = set()
|
||||
for tool in self.tools:
|
||||
@@ -2171,7 +2338,7 @@ Summary:"""
|
||||
elif hasattr(tool, 'name'):
|
||||
tool_names.add(tool.name)
|
||||
|
||||
# Add read_file if not present
|
||||
# Add read_file if not present (low risk, always enabled)
|
||||
if 'read_file' not in tool_names:
|
||||
try:
|
||||
from ..tools import read_file
|
||||
@@ -2180,8 +2347,26 @@ Summary:"""
|
||||
except ImportError:
|
||||
logging.warning("Could not import read_file tool for skills")
|
||||
|
||||
# Add run_skill_script from skill_tools module
|
||||
if 'run_skill_script' not in tool_names:
|
||||
# G-E fix: run_skill_script safer by default
|
||||
# Only add if explicitly enabled OR any skill declares it in allowed-tools
|
||||
should_add_script_tool = False
|
||||
|
||||
# Check explicit environment enable
|
||||
if _os.environ.get("PRAISONAI_ENABLE_SKILL_TOOLS") in ("1", "true", "True"):
|
||||
should_add_script_tool = True
|
||||
logging.debug("run_skill_script enabled via PRAISONAI_ENABLE_SKILL_TOOLS")
|
||||
|
||||
# Check if any loaded skill declares it in allowed-tools
|
||||
if self._skill_manager and not should_add_script_tool:
|
||||
for skill in self._skill_manager.skills:
|
||||
allowed_tools = self._skill_manager.get_allowed_tools(skill.properties.name)
|
||||
if "run_skill_script" in allowed_tools:
|
||||
should_add_script_tool = True
|
||||
logging.debug(f"run_skill_script enabled by skill '{skill.properties.name}' allowed-tools")
|
||||
break
|
||||
|
||||
# Add run_skill_script if conditions are met
|
||||
if should_add_script_tool and 'run_skill_script' not in tool_names:
|
||||
try:
|
||||
from ..tools.skill_tools import create_skill_tools
|
||||
# Create skill tools with current working directory
|
||||
@@ -2712,6 +2897,19 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# G2: Check for interrupt request (cooperative cancellation) - sync version
|
||||
if self.interrupt_controller and self.interrupt_controller.is_set():
|
||||
reason = self.interrupt_controller.reason or "unknown"
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=f"Task interrupted: {reason}",
|
||||
completion_reason="interrupted",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Execute one turn using the agent's chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
@@ -3109,6 +3307,20 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# G2: Check for interrupt request (cooperative cancellation)
|
||||
if self.interrupt_controller and self.interrupt_controller.is_set():
|
||||
reason = self.interrupt_controller.reason or "unknown"
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=f"Task interrupted: {reason}",
|
||||
completion_reason="interrupted",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
# Execute one turn using the agent's async chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
@@ -4481,9 +4693,141 @@ Answer:"""
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
# -------------------------------------------------------------------------
|
||||
# CLI Backend Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve_cli_backend(self, cli_backend):
|
||||
"""Validate and return CLI backend protocol instance.
|
||||
|
||||
Args:
|
||||
cli_backend: CliBackendProtocol instance or callable that returns one
|
||||
|
||||
Returns:
|
||||
CliBackendProtocol instance
|
||||
|
||||
Note:
|
||||
String backend IDs must be resolved to instances in the wrapper layer
|
||||
before passing to Agent. This maintains proper dependency direction
|
||||
per AGENTS.md (core SDK should not import from wrapper).
|
||||
"""
|
||||
# Import protocols
|
||||
try:
|
||||
from ..cli_backend import CliBackendProtocol
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"CLI backend features requested but protocols not available. "
|
||||
"This should not happen in a properly installed praisonaiagents package."
|
||||
)
|
||||
|
||||
# If already a protocol instance, return as-is
|
||||
if hasattr(cli_backend, 'execute') and hasattr(cli_backend, 'stream'):
|
||||
return cli_backend
|
||||
|
||||
# If callable (factory function), call it to get instance
|
||||
if callable(cli_backend):
|
||||
instance = cli_backend()
|
||||
if hasattr(instance, 'execute') and hasattr(instance, 'stream'):
|
||||
return instance
|
||||
raise TypeError(f"CLI backend factory returned invalid type: {type(instance)}. Expected CliBackendProtocol.")
|
||||
|
||||
# String IDs are no longer supported at core level - must be resolved in wrapper
|
||||
if isinstance(cli_backend, str):
|
||||
raise TypeError(
|
||||
f"String CLI backend IDs ('{cli_backend}') must be resolved to instances "
|
||||
"in the wrapper layer. Use: praisonai.Agent(cli_backend='claude-code') "
|
||||
"or manually resolve: agent = praisonaiagents.Agent(cli_backend=resolve_cli_backend('claude-code'))"
|
||||
)
|
||||
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
|
||||
|
||||
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Chat implementation using CLI backend delegation.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Additional chat parameters (passed through as metadata)
|
||||
|
||||
Returns:
|
||||
CLI backend response content
|
||||
"""
|
||||
if not self._cli_backend:
|
||||
raise RuntimeError("CLI backend not configured")
|
||||
|
||||
try:
|
||||
# Import backend types
|
||||
from ..cli_backend import CliSessionBinding
|
||||
|
||||
# Build session binding for state management
|
||||
session_id = getattr(self, '_session_id', None) or f"agent-{self.agent_id}"
|
||||
session_binding = CliSessionBinding(session_id=session_id)
|
||||
|
||||
# Get system prompt from agent configuration
|
||||
system_prompt = None
|
||||
if hasattr(self, 'system_prompt') and self.system_prompt:
|
||||
system_prompt = self.system_prompt
|
||||
elif self.backstory or self.role or self.goal:
|
||||
# Build system prompt from agent identity
|
||||
parts = []
|
||||
if self.backstory:
|
||||
parts.append(self.backstory)
|
||||
if self.role:
|
||||
parts.append(f"Your Role: {self.role}")
|
||||
if self.goal:
|
||||
parts.append(f"Your Goal: {self.goal}")
|
||||
system_prompt = "\n".join(parts)
|
||||
|
||||
# Extract images from attachments (if any)
|
||||
images = kwargs.get('attachments')
|
||||
if images:
|
||||
# Filter for image files
|
||||
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
||||
images = [
|
||||
img for img in images
|
||||
if any(img.lower().endswith(ext) for ext in image_extensions)
|
||||
]
|
||||
if not images:
|
||||
images = None
|
||||
|
||||
# Execute CLI backend
|
||||
result = await self._cli_backend.execute(
|
||||
prompt=prompt,
|
||||
session=session_binding,
|
||||
images=images,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
# Check for CLI backend errors
|
||||
if result is None:
|
||||
raise RuntimeError(
|
||||
f"CLI backend returned no result for agent={self.display_name!r}, "
|
||||
f"session_id={session_id!r}"
|
||||
)
|
||||
if getattr(result, "error", None):
|
||||
raise RuntimeError(
|
||||
f"CLI backend failed for agent={self.display_name!r}, "
|
||||
f"session_id={session_id!r}: {result.error}"
|
||||
)
|
||||
|
||||
# Update chat history with the exchange
|
||||
if hasattr(self, '_append_to_chat_history'):
|
||||
self._append_to_chat_history({
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
})
|
||||
if result.content:
|
||||
self._append_to_chat_history({
|
||||
"role": "assistant",
|
||||
"content": result.content
|
||||
})
|
||||
|
||||
return result.content if result else None
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"CLI backend execution failed for agent={self.display_name!r}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource Lifecycle Management
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -4501,6 +4845,39 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory cleanup failed: {e}")
|
||||
|
||||
# LLM client cleanup - target actual live clients, not model strings
|
||||
try:
|
||||
# Primary cleanup targets - actual live clients
|
||||
if hasattr(self, 'llm_instance') and self.llm_instance:
|
||||
if hasattr(self.llm_instance, 'aclose'):
|
||||
# Try async close first
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(self.llm_instance.aclose):
|
||||
# Use async bridge to safely close from any context
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
run_coroutine_from_any_context(self.llm_instance.aclose())
|
||||
else:
|
||||
self.llm_instance.aclose()
|
||||
except Exception:
|
||||
# Fall back to sync close if async fails
|
||||
if hasattr(self.llm_instance, 'close'):
|
||||
self.llm_instance.close()
|
||||
elif hasattr(self.llm_instance, 'close'):
|
||||
self.llm_instance.close()
|
||||
|
||||
# Check for OpenAI client (common pattern in agents)
|
||||
if hasattr(self, '_Agent__openai_client') and self._Agent__openai_client:
|
||||
if hasattr(self._Agent__openai_client, 'close'):
|
||||
self._Agent__openai_client.close()
|
||||
|
||||
# Legacy fallback - check self.llm._client (but less likely to work)
|
||||
if hasattr(self, 'llm') and self.llm and not isinstance(self.llm, str):
|
||||
llm_client = getattr(self.llm, '_client', None)
|
||||
if llm_client and hasattr(llm_client, 'close'):
|
||||
llm_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM client cleanup failed: {e}")
|
||||
|
||||
# MCP cleanup
|
||||
try:
|
||||
if hasattr(self, '_mcp_clients') and self._mcp_clients:
|
||||
@@ -4525,6 +4902,19 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logger.warning(f"Task cleanup failed: {e}")
|
||||
|
||||
# ThreadPoolExecutor cleanup
|
||||
try:
|
||||
if hasattr(self, '_tool_executor') and self._tool_executor:
|
||||
# Use cancel_futures only if supported (Python 3.9+)
|
||||
import sys
|
||||
if sys.version_info >= (3, 9):
|
||||
self._tool_executor.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
self._tool_executor.shutdown(wait=False)
|
||||
delattr(self, '_tool_executor')
|
||||
except Exception as e:
|
||||
logger.warning(f"ThreadPoolExecutor cleanup failed: {e}")
|
||||
|
||||
# Always set closed flag
|
||||
self._closed = True
|
||||
|
||||
@@ -4559,6 +4949,23 @@ Answer:"""
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ThreadPoolExecutor cleanup (async-safe)
|
||||
if hasattr(self, '_tool_executor') and self._tool_executor:
|
||||
import sys
|
||||
loop = asyncio.get_running_loop()
|
||||
# Use run_in_executor to avoid blocking the event loop
|
||||
if sys.version_info >= (3, 9):
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._tool_executor.shutdown(wait=False, cancel_futures=True)
|
||||
)
|
||||
else:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._tool_executor.shutdown(wait=False)
|
||||
)
|
||||
delattr(self, '_tool_executor')
|
||||
|
||||
self._closed = True
|
||||
|
||||
@@ -4571,28 +4978,7 @@ Answer:"""
|
||||
return # No ID generated, nothing registered
|
||||
|
||||
try:
|
||||
agent_id = self._agent_id
|
||||
with _server_lock:
|
||||
# Remove from _registered_agents
|
||||
ports_to_clean = []
|
||||
for port, path_dict in _registered_agents.items():
|
||||
paths_to_remove = []
|
||||
for path, registered_id in path_dict.items():
|
||||
if registered_id == agent_id:
|
||||
paths_to_remove.append(path)
|
||||
|
||||
for path in paths_to_remove:
|
||||
del path_dict[path]
|
||||
|
||||
# If no paths left for this port, mark port for cleanup
|
||||
if not path_dict:
|
||||
ports_to_clean.append(port)
|
||||
|
||||
# Clean up empty port entries
|
||||
for port in ports_to_clean:
|
||||
_registered_agents.pop(port, None)
|
||||
_server_started.pop(port, None)
|
||||
# Note: We don't clean up _shared_apps here as other agents might be using them
|
||||
_get_default_server_registry().cleanup_agent_registrations(self._agent_id)
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
@@ -4619,8 +5005,21 @@ Answer:"""
|
||||
await self.aclose()
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor safely does nothing to avoid GC pollution in test loops."""
|
||||
pass
|
||||
"""Lightweight cleanup that only closes resources if not already closed."""
|
||||
if not getattr(self, '_closed', False):
|
||||
# Only close connections, skip anything that could fail during GC
|
||||
try:
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory and hasattr(memory, 'close_connections'):
|
||||
memory.close_connections()
|
||||
except Exception as exc: # noqa: BLE001 - finalizers must not raise
|
||||
import contextlib
|
||||
with contextlib.suppress(Exception):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Agent GC memory cleanup failed: %s", exc)
|
||||
finally:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
@@ -4629,4 +5028,3 @@ Answer:"""
|
||||
|
||||
def __str__(self):
|
||||
return f"Agent(name='{self.name}', role='{self.role}', goal='{self.goal}')"
|
||||
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import threading
|
||||
from typing import Any, Optional, Union
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
|
||||
class DualLock:
|
||||
@@ -28,33 +29,16 @@ class DualLock:
|
||||
pass
|
||||
|
||||
# In async context
|
||||
async with lock.async():
|
||||
async with lock.async_lock():
|
||||
# Uses asyncio.Lock
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._thread_lock = threading.Lock()
|
||||
self._async_lock: Optional[asyncio.Lock] = None
|
||||
self._loop_id: Optional[int] = None
|
||||
|
||||
def _get_async_lock(self) -> asyncio.Lock:
|
||||
"""Get or create asyncio.Lock for current event loop."""
|
||||
try:
|
||||
current_loop = asyncio.get_running_loop()
|
||||
current_loop_id = id(current_loop)
|
||||
|
||||
# Create new lock if loop changed or first time
|
||||
if self._loop_id != current_loop_id:
|
||||
self._async_lock = asyncio.Lock()
|
||||
self._loop_id = current_loop_id
|
||||
|
||||
return self._async_lock
|
||||
except RuntimeError:
|
||||
# No event loop running, fall back to thread lock in a new loop
|
||||
self._async_lock = asyncio.Lock()
|
||||
return self._async_lock
|
||||
"""Initialize with separate threading and asyncio locks."""
|
||||
self._thread_lock = threading.RLock() # Re-entrant lock to handle nested acquisitions
|
||||
self._async_locks = WeakKeyDictionary() # Per-event-loop async locks
|
||||
|
||||
@contextmanager
|
||||
def sync(self):
|
||||
@@ -62,9 +46,24 @@ class DualLock:
|
||||
with self._thread_lock:
|
||||
yield
|
||||
|
||||
def _get_async_lock(self):
|
||||
"""Get or create an asyncio.Lock for the current event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError("async_lock() must be called from an async context")
|
||||
|
||||
if loop not in self._async_locks:
|
||||
self._async_locks[loop] = asyncio.Lock()
|
||||
return self._async_locks[loop]
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_lock(self):
|
||||
"""Acquire lock in asynchronous context using asyncio.Lock."""
|
||||
"""Acquire lock in asynchronous context using asyncio.Lock.
|
||||
|
||||
Uses a per-event-loop asyncio.Lock to ensure proper async coordination
|
||||
without blocking the event loop or violating thread ownership semantics.
|
||||
"""
|
||||
async_lock = self._get_async_lock()
|
||||
async with async_lock:
|
||||
yield
|
||||
|
||||
@@ -233,8 +233,21 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
try:
|
||||
from ..llm.model_capabilities import supports_structured_outputs
|
||||
except ImportError:
|
||||
return False # Module genuinely not available — acceptable
|
||||
|
||||
try:
|
||||
return supports_structured_outputs(self.llm)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
"Structured output capability check failed for agent %s (model=%r); "
|
||||
"falling back to prompt-based schema formatting. Check model capability "
|
||||
"configuration and optional provider dependencies: %s",
|
||||
getattr(self, "name", "<unknown>"),
|
||||
self.llm,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _build_messages(self, prompt, temperature=1.0, output_json=None, output_pydantic=None, tools=None, use_native_format=False):
|
||||
@@ -457,6 +470,29 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return content
|
||||
|
||||
def _extract_llm_response_content(self, response) -> Optional[str]:
|
||||
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
|
||||
if not response:
|
||||
return None
|
||||
try:
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
choice = response.choices[0]
|
||||
msg = getattr(choice, "message", None)
|
||||
if msg is not None:
|
||||
content = getattr(msg, "content", None)
|
||||
if content:
|
||||
return content
|
||||
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||
if tool_calls:
|
||||
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
|
||||
return f"[tool_calls: {', '.join(names)}]"
|
||||
except (AttributeError, IndexError, TypeError) as e:
|
||||
logging.warning(
|
||||
f"Failed to extract LLM response content (falling back to str): {e}"
|
||||
)
|
||||
# Fallback to str(response) is still fine, but now it's visible
|
||||
return str(response)
|
||||
|
||||
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
|
||||
"""Internal helper for streaming response processing with real-time events."""
|
||||
if self._openai_client is None:
|
||||
@@ -475,7 +511,7 @@ Your Goal: {self.goal}"""
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
|
||||
@@ -490,8 +526,10 @@ Your Goal: {self.goal}"""
|
||||
if _compactor.needs_compaction(messages):
|
||||
try:
|
||||
self._hook_runner.execute_sync(_HookEvent.BEFORE_COMPACTION, None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.warning(f"BEFORE_COMPACTION hook failed: {e}")
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
compacted_msgs, _cr = _compactor.compact(messages)
|
||||
messages[:] = compacted_msgs # in-place update so callers see the change
|
||||
logging.info(
|
||||
@@ -500,9 +538,13 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
try:
|
||||
self._hook_runner.execute_sync(_HookEvent.AFTER_COMPACTION, None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.warning(f"AFTER_COMPACTION hook failed: {e}")
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
except Exception as _ce:
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
# Trigger BEFORE_LLM hook
|
||||
@@ -536,121 +578,23 @@ Your Goal: {self.goal}"""
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# NEW: Unified protocol dispatch path (Issue #1304)
|
||||
# Check if unified dispatch is enabled (opt-in for backward compatibility)
|
||||
if getattr(self, '_use_unified_llm_dispatch', False):
|
||||
# Use composition instead of runtime class mutation for safety
|
||||
final_response = self._execute_unified_chat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
|
||||
# LEGACY: Maintain existing dual execution paths for backward compatibility
|
||||
elif self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
if stream:
|
||||
# Debug logs for tool info
|
||||
if formatted_tools:
|
||||
logging.debug(f"Passing {len(formatted_tools)} formatted tools to LLM instance: {formatted_tools}")
|
||||
|
||||
# Use the LLM instance for streaming responses
|
||||
final_response = self.llm_instance.get_response(
|
||||
prompt=messages[1:], # Skip system message as LLM handles it separately
|
||||
system_prompt=messages[0]['content'] if messages and messages[0]['role'] == 'system' else None,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools if formatted_tools else None,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
stream=stream,
|
||||
console=self.console,
|
||||
execute_tool_fn=self.execute_tool,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[getattr(t, '__name__', str(t)) for t in self.tools] if self.tools else None,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
reasoning_steps=reasoning_steps
|
||||
)
|
||||
else:
|
||||
# Non-streaming with custom LLM - don't show streaming-like behavior
|
||||
if False: # Don't use display_generating when stream=False to avoid streaming-like behavior
|
||||
# This block is disabled to maintain consistency with the OpenAI path fix
|
||||
with _get_live()(
|
||||
_get_display_functions()['display_generating']("", start_time),
|
||||
console=self.console,
|
||||
refresh_per_second=4,
|
||||
) as live:
|
||||
final_response = self.llm_instance.get_response(
|
||||
prompt=messages[1:],
|
||||
system_prompt=messages[0]['content'] if messages and messages[0]['role'] == 'system' else None,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools if formatted_tools else None,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
stream=stream,
|
||||
console=self.console,
|
||||
execute_tool_fn=self.execute_tool,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[getattr(t, '__name__', str(t)) for t in self.tools] if self.tools else None,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
reasoning_steps=reasoning_steps
|
||||
)
|
||||
else:
|
||||
final_response = self.llm_instance.get_response(
|
||||
prompt=messages[1:],
|
||||
system_prompt=messages[0]['content'] if messages and messages[0]['role'] == 'system' else None,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools if formatted_tools else None,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
stream=stream,
|
||||
console=self.console,
|
||||
execute_tool_fn=self.execute_tool,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[getattr(t, '__name__', str(t)) for t in self.tools] if self.tools else None,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
reasoning_steps=reasoning_steps
|
||||
)
|
||||
else:
|
||||
# Use the standard OpenAI client approach with tool support
|
||||
# Note: openai_client expects tools in various formats and will format them internally
|
||||
# But since we already have formatted_tools, we can pass them directly
|
||||
if self._openai_client is None:
|
||||
raise ValueError("OpenAI client is not initialized. Please provide OPENAI_API_KEY or use a custom LLM provider.")
|
||||
|
||||
# Build kwargs including response_format if provided
|
||||
chat_kwargs = {
|
||||
"messages": messages,
|
||||
"model": self.llm,
|
||||
"temperature": temperature,
|
||||
"tools": formatted_tools, # Already formatted for OpenAI
|
||||
"execute_tool_fn": self.execute_tool,
|
||||
"stream": stream,
|
||||
"console": self.console if (self.verbose or stream) else None,
|
||||
"display_fn": self._display_generating if self.verbose else None,
|
||||
"reasoning_steps": reasoning_steps,
|
||||
"verbose": self.verbose,
|
||||
"max_iterations": 10,
|
||||
"stream_callback": self.stream_emitter.emit,
|
||||
"emit_events": True,
|
||||
}
|
||||
if response_format:
|
||||
chat_kwargs["response_format"] = response_format
|
||||
|
||||
final_response = self._openai_client.chat_completion_with_tools(**chat_kwargs)
|
||||
# NEW: Unified protocol dispatch path (Issue #1304, #1362)
|
||||
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
|
||||
# All LLM providers now go through unified dispatcher for consistency and maintainability
|
||||
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
final_response = self._execute_unified_chat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=True,
|
||||
)
|
||||
|
||||
# Emit LLM response trace event with token usage
|
||||
_duration_ms = (time.time() - start_time) * 1000
|
||||
@@ -670,33 +614,38 @@ Your Goal: {self.goal}"""
|
||||
_trace_emitter.llm_response(
|
||||
self.name,
|
||||
duration_ms=_duration_ms,
|
||||
response_content=str(final_response) if final_response else None,
|
||||
response_content=self._extract_llm_response_content(final_response),
|
||||
prompt_tokens=_prompt_tokens,
|
||||
completion_tokens=_completion_tokens,
|
||||
cost_usd=_cost_usd,
|
||||
)
|
||||
|
||||
# Budget tracking & enforcement (zero overhead when _max_budget is None)
|
||||
self._total_cost += _cost_usd
|
||||
self._total_tokens_in += _prompt_tokens
|
||||
self._total_tokens_out += _completion_tokens
|
||||
self._llm_call_count += 1
|
||||
if self._max_budget and self._total_cost >= self._max_budget:
|
||||
# Thread-safe cost tracking (Gap 1a fix)
|
||||
with self._cost_lock:
|
||||
self._total_cost += _cost_usd
|
||||
self._total_tokens_in += _prompt_tokens
|
||||
self._total_tokens_out += _completion_tokens
|
||||
self._llm_call_count += 1
|
||||
budget_exceeded = self._max_budget and self._total_cost >= self._max_budget
|
||||
current_cost = self._total_cost
|
||||
|
||||
if budget_exceeded:
|
||||
if self._on_budget_exceeded == "stop":
|
||||
raise BudgetExceededError(
|
||||
f"Agent '{self.name}' exceeded budget: ${self._total_cost:.4f} >= ${self._max_budget:.4f}",
|
||||
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
|
||||
budget_type="cost",
|
||||
limit=self._max_budget,
|
||||
used=self._total_cost,
|
||||
used=current_cost,
|
||||
agent_id=self.name
|
||||
)
|
||||
elif self._on_budget_exceeded == "warn":
|
||||
logging.warning(
|
||||
f"[budget] {self.name}: ${self._total_cost:.4f} exceeded "
|
||||
f"[budget] {self.name}: ${current_cost:.4f} exceeded "
|
||||
f"${self._max_budget:.4f} budget"
|
||||
)
|
||||
elif callable(self._on_budget_exceeded):
|
||||
self._on_budget_exceeded(self._total_cost, self._max_budget)
|
||||
self._on_budget_exceeded(current_cost, self._max_budget)
|
||||
|
||||
# Trigger AFTER_LLM hook
|
||||
from ..hooks import HookEvent, AfterLLMInput
|
||||
@@ -718,6 +667,7 @@ Your Goal: {self.goal}"""
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
error_str = str(e).lower()
|
||||
|
||||
# Check if this is a context overflow error
|
||||
@@ -750,11 +700,22 @@ Your Goal: {self.goal}"""
|
||||
f"{estimate_messages_tokens(truncated_messages)} tokens"
|
||||
)
|
||||
|
||||
# Retry with truncated messages (recursive call with truncated context)
|
||||
return self._chat_completion(
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format
|
||||
)
|
||||
# Retry with truncated messages (recursive call with depth limit)
|
||||
if _retry_depth < 2:
|
||||
return self._chat_completion(
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1
|
||||
)
|
||||
else:
|
||||
logging.error(f"[{self.name}] Context overflow retry limit exceeded")
|
||||
raise LLMError(
|
||||
f"Context overflow could not be resolved after {_retry_depth} attempts",
|
||||
model_name=model_name, agent_id=self.name, is_retryable=False
|
||||
) from e
|
||||
except LLMError:
|
||||
# Re-raise LLMError (including depth limit errors) without swallowing
|
||||
raise
|
||||
except Exception as recovery_error:
|
||||
logging.error(f"[{self.name}] Overflow recovery failed: {recovery_error}")
|
||||
|
||||
@@ -766,8 +727,47 @@ Your Goal: {self.goal}"""
|
||||
finish_reason="error",
|
||||
response_content=str(e), # Include error for context replay
|
||||
)
|
||||
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
|
||||
return None
|
||||
|
||||
# Classify and raise structured error with improved transient failure detection
|
||||
model_name = self.llm if isinstance(self.llm, str) else "unknown"
|
||||
session_id = getattr(self, '_session_id', 'unknown')
|
||||
|
||||
# Check for retryable errors (rate limits, transient network issues, provider errors)
|
||||
retryable_indicators = [
|
||||
"rate limit", "429", "too many requests",
|
||||
"timeout", "connection reset", "connection error", "socket error",
|
||||
"500", "502", "503", "504", "service unavailable", "internal server error",
|
||||
"dns", "network", "connection refused"
|
||||
]
|
||||
|
||||
# Check for non-retryable errors (auth issues)
|
||||
auth_indicators = ["401", "403", "authentication", "unauthorized", "invalid_api_key"]
|
||||
|
||||
if any(phrase in error_str.lower() for phrase in retryable_indicators):
|
||||
is_retryable = True
|
||||
elif any(phrase in error_str.lower() for phrase in auth_indicators):
|
||||
is_retryable = False
|
||||
else:
|
||||
# Default to retryable for unknown errors to be more resilient
|
||||
is_retryable = True
|
||||
|
||||
# Create LLMError with contextual metadata
|
||||
error = LLMError(
|
||||
str(e),
|
||||
model_name=model_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Call error hook if available for error interception
|
||||
if hasattr(self, 'on_error') and self.on_error:
|
||||
try:
|
||||
self.on_error(error)
|
||||
except Exception as hook_error:
|
||||
logging.debug(f"Error in on_error hook: {hook_error}")
|
||||
|
||||
raise error from e
|
||||
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
@@ -779,7 +779,9 @@ Your Goal: {self.goal}"""
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
response_format=None
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
):
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
@@ -805,10 +807,14 @@ Your Goal: {self.goal}"""
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Execute unified dispatch with all necessary parameters
|
||||
# Includes all parameters from both legacy paths to ensure full compatibility
|
||||
try:
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
@@ -816,14 +822,29 @@ Your Goal: {self.goal}"""
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or stream) else None,
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=getattr(self.stream_emitter, 'emit', None) if hasattr(self, 'stream_emitter') else None,
|
||||
emit_events=True,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id
|
||||
task_id=task_id,
|
||||
# Additional parameters from legacy custom LLM path
|
||||
markdown=self.markdown,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=(
|
||||
[
|
||||
t["function"]["name"]
|
||||
if isinstance(t, dict)
|
||||
and isinstance(t.get("function"), dict)
|
||||
and "name" in t["function"]
|
||||
else getattr(t, "__name__", str(t))
|
||||
for t in (tools if tools is not None else self.tools or [])
|
||||
]
|
||||
or None
|
||||
),
|
||||
)
|
||||
return final_response
|
||||
|
||||
@@ -841,7 +862,9 @@ Your Goal: {self.goal}"""
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
response_format=None
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
):
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
@@ -867,10 +890,14 @@ Your Goal: {self.goal}"""
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Execute unified async dispatch with all necessary parameters
|
||||
# Includes all parameters from both legacy paths to ensure full compatibility
|
||||
try:
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = await self._unified_dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
@@ -878,14 +905,29 @@ Your Goal: {self.goal}"""
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or stream) else None,
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=getattr(self.stream_emitter, 'emit', None) if hasattr(self, 'stream_emitter') else None,
|
||||
emit_events=True,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id
|
||||
task_id=task_id,
|
||||
# Additional parameters from legacy custom LLM path
|
||||
markdown=self.markdown,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=(
|
||||
[
|
||||
t["function"]["name"]
|
||||
if isinstance(t, dict)
|
||||
and isinstance(t.get("function"), dict)
|
||||
and "name" in t["function"]
|
||||
else getattr(t, "__name__", str(t))
|
||||
for t in (tools if tools is not None else self.tools or [])
|
||||
]
|
||||
or None
|
||||
),
|
||||
)
|
||||
return final_response
|
||||
|
||||
@@ -919,7 +961,17 @@ Your Goal: {self.goal}"""
|
||||
generation_time=generation_time, console=self.console,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[getattr(t, '__name__', str(t)) for t in self.tools] if self.tools else None,
|
||||
agent_tools=(
|
||||
[
|
||||
t["function"]["name"]
|
||||
if isinstance(t, dict)
|
||||
and isinstance(t.get("function"), dict)
|
||||
and "name" in t["function"]
|
||||
else getattr(t, "__name__", str(t))
|
||||
for t in (tools if tools is not None else self.tools or [])
|
||||
]
|
||||
or None
|
||||
),
|
||||
task_name=None, # Not available in this context
|
||||
task_description=None, # Not available in this context
|
||||
task_id=None) # Not available in this context
|
||||
@@ -1036,6 +1088,67 @@ Your Goal: {self.goal}"""
|
||||
logging.warning(f"Tool output truncation error: {e}")
|
||||
return output
|
||||
|
||||
def _resolve_skill_invocation(self, prompt):
|
||||
"""If ``prompt`` is ``/skill-name [args]``, render the skill body.
|
||||
|
||||
Returns:
|
||||
The rendered prompt when a user-invocable skill matches, else
|
||||
the original ``prompt`` unchanged. Non-string prompts (e.g.
|
||||
multimodal lists) are returned as-is.
|
||||
"""
|
||||
if not isinstance(prompt, str):
|
||||
return prompt
|
||||
text = prompt.lstrip()
|
||||
if not text.startswith("/"):
|
||||
return prompt
|
||||
# Avoid path-like "/usr/..." inputs
|
||||
head = text[1:].split(None, 1)
|
||||
if not head:
|
||||
return prompt
|
||||
name = head[0]
|
||||
args = head[1] if len(head) > 1 else ""
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
||||
return prompt
|
||||
mgr = getattr(self, "skill_manager", None)
|
||||
if mgr is None:
|
||||
return prompt
|
||||
rendered = mgr.invoke(name, raw_args=args)
|
||||
if rendered is None:
|
||||
return prompt
|
||||
# G-A fix: Best-effort pre-approve any tools declared under
|
||||
# `allowed-tools` in the skill frontmatter. Non-fatal on error.
|
||||
try:
|
||||
tool_names = mgr.get_allowed_tools(name)
|
||||
if tool_names:
|
||||
from ..approval import get_approval_registry
|
||||
|
||||
registry = get_approval_registry()
|
||||
agent_name = getattr(self, "display_name", getattr(self, "name", None))
|
||||
if agent_name: # Only approve if we have a stable agent identifier
|
||||
for _tn in tool_names:
|
||||
try:
|
||||
registry.auto_approve_tool(_tn, agent_name=agent_name)
|
||||
except Exception as exc: # pragma: no cover - approval is optional
|
||||
logging.debug(
|
||||
"Failed to auto-approve skill tool '%s' for skill '%s' on agent '%s': %s. "
|
||||
"The skill will continue, but this tool may still require explicit approval.",
|
||||
_tn,
|
||||
name,
|
||||
agent_name,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - approval is optional
|
||||
logging.debug(
|
||||
"Failed to resolve allowed tools for skill '%s' on agent '%s': %s. "
|
||||
"The skill will continue without pre-approving tools.",
|
||||
name,
|
||||
getattr(self, "name", None),
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
@@ -1049,6 +1162,10 @@ Your Goal: {self.goal}"""
|
||||
'required' forces the LLM to call a tool before responding.
|
||||
...other args...
|
||||
"""
|
||||
# Slash-command invocation: /skill-name [args] renders the skill
|
||||
# body before any backend/LLM call.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
|
||||
# Check if external managed backend is configured
|
||||
if hasattr(self, 'backend') and self.backend is not None:
|
||||
# Extract kwargs for delegation, excluding 'self' and function locals
|
||||
@@ -1291,6 +1408,7 @@ Your Goal: {self.goal}"""
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
@@ -1321,11 +1439,11 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
# Rollback chat history if LLM call fails
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -1366,7 +1484,7 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB (OpenAI path)
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
@@ -1410,7 +1528,7 @@ Your Goal: {self.goal}"""
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools if tools else None, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Handle None content (can happen with tool calls or empty responses)
|
||||
@@ -1421,7 +1539,7 @@ Your Goal: {self.goal}"""
|
||||
if output_json or output_pydantic:
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
@@ -1433,12 +1551,12 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
@@ -1454,7 +1572,7 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
@@ -1465,7 +1583,7 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
reflection_prompt = f"""
|
||||
@@ -1524,7 +1642,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after satisfactory reflection
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -1535,7 +1653,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
self._end_run(None, "error", {"error": str(e)})
|
||||
return None
|
||||
|
||||
@@ -1544,7 +1662,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after max reflections
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -1554,7 +1672,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# If not satisfactory and not at max reflections, continue with regeneration
|
||||
@@ -1581,7 +1699,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Catch any exceptions that escape the while loop
|
||||
_get_display_functions()['display_error'](f"Unexpected error in chat: {e}", console=self.console)
|
||||
# Rollback chat history
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
def clean_json_output(self, output: str) -> str:
|
||||
@@ -1604,6 +1722,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
attachments: Optional list of image/file paths that are ephemeral
|
||||
(used for THIS turn only, NEVER stored in history).
|
||||
"""
|
||||
# Slash-command invocation: /skill-name [args] renders the skill body.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
|
||||
# Emit context trace event (zero overhead when not set)
|
||||
from ..trace.context_events import get_context_emitter
|
||||
_trace_emitter = get_context_emitter()
|
||||
@@ -1710,7 +1831,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
# --- Context compaction (async custom LLM path) ---
|
||||
_exec_cfg = getattr(self, 'execution', None)
|
||||
@@ -1723,19 +1844,25 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if _cw.needs_compaction(self.chat_history):
|
||||
try:
|
||||
await self._hook_runner.execute(_HE.BEFORE_COMPACTION, None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.warning(f"BEFORE_COMPACTION hook failed: {e}")
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
_ch, _cr = _cw.compact(self.chat_history)
|
||||
self.chat_history[:] = _ch
|
||||
self._replace_chat_history(_ch)
|
||||
logging.info(
|
||||
f"[compaction] {self.name}: {_cr.original_tokens}→{_cr.compacted_tokens} tokens "
|
||||
f"({_cr.messages_removed} messages removed)"
|
||||
)
|
||||
try:
|
||||
await self._hook_runner.execute(_HE.AFTER_COMPACTION, None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.warning(f"AFTER_COMPACTION hook failed: {e}")
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
except Exception as _ce:
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
try:
|
||||
@@ -1760,11 +1887,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool_async,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
@@ -1779,11 +1907,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
# Rollback chat history if LLM call fails
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
@@ -1808,7 +1936,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
# --- Context compaction (async standard OpenAI path) ---
|
||||
_exec_cfg2 = getattr(self, 'execution', None)
|
||||
@@ -1860,8 +1988,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
# NEW: Unified protocol dispatch path (Issue #1304) - Async version
|
||||
# Check if unified dispatch is enabled (opt-in for backward compatibility)
|
||||
if getattr(self, '_use_unified_llm_dispatch', False):
|
||||
# Enable unified dispatch by default for DRY and feature parity (sync/async consistent)
|
||||
if getattr(self, '_use_unified_llm_dispatch', True):
|
||||
# Build response_format for native structured output (parity with sync path)
|
||||
schema_model = output_pydantic or output_json
|
||||
response_format = None
|
||||
if schema_model and self._supports_native_structured_output():
|
||||
response_format = self._build_response_format(schema_model)
|
||||
|
||||
# Use composition instead of runtime class mutation for safety
|
||||
response = await self._execute_unified_achat_completion(
|
||||
messages=messages,
|
||||
@@ -1952,8 +2086,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
|
||||
# Return the original response without reflection
|
||||
self.chat_history.append({"role": "user", "content": original_prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "user", "content": original_prompt})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
@@ -2019,7 +2153,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
|
||||
@@ -2081,13 +2215,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as _hook_err:
|
||||
logging.debug(f"BEFORE_TOOL hook error (non-fatal): {_hook_err}")
|
||||
|
||||
# Check if the tool is async
|
||||
if asyncio.iscoroutinefunction(tool):
|
||||
result = await tool(**arguments)
|
||||
else:
|
||||
# Run sync function in executor to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, lambda: tool(**arguments))
|
||||
# Route through safety pipeline instead of direct execution
|
||||
# Pass the tools list to honor task-scoped tools
|
||||
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
|
||||
|
||||
# --- AFTER_TOOL hook ---
|
||||
try:
|
||||
@@ -2262,7 +2392,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not (self.chat_history and
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
try:
|
||||
# Use the new streaming generator from LLM class
|
||||
@@ -2283,18 +2413,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_name=kwargs.get('task_name'),
|
||||
task_description=kwargs.get('task_description'),
|
||||
task_id=kwargs.get('task_id'),
|
||||
execute_tool_fn=self.execute_tool
|
||||
execute_tool_fn=self.execute_tool,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False)
|
||||
):
|
||||
response_content += chunk
|
||||
yield chunk
|
||||
|
||||
# Add complete response to chat history
|
||||
if response_content:
|
||||
self.chat_history.append({"role": "assistant", "content": response_content})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_content})
|
||||
|
||||
except Exception as e:
|
||||
# Rollback chat history on error
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
logging.error(f"Custom LLM streaming error: {e}")
|
||||
raise
|
||||
|
||||
@@ -2337,7 +2468,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not (self.chat_history and
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
try:
|
||||
# Check if OpenAI client is available
|
||||
@@ -2449,7 +2580,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"function": tc['function']
|
||||
} for tc in tool_calls_data if tc['id']
|
||||
]
|
||||
self.chat_history.append(assistant_message)
|
||||
self._append_to_chat_history(assistant_message)
|
||||
|
||||
# Execute tool calls and add results to chat history
|
||||
for tool_call in tool_calls_data:
|
||||
@@ -2468,7 +2599,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_id=tool_call.get('id')
|
||||
)
|
||||
# Add tool result to chat history
|
||||
self.chat_history.append({
|
||||
self._append_to_chat_history({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call['id'],
|
||||
"content": str(tool_result)
|
||||
@@ -2476,7 +2607,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as tool_error:
|
||||
logging.error(f"Tool execution error in streaming: {tool_error}")
|
||||
# Add error result to chat history
|
||||
self.chat_history.append({
|
||||
self._append_to_chat_history({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call['id'],
|
||||
"content": f"Error: {str(tool_error)}"
|
||||
@@ -2484,11 +2615,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
# Add complete response to chat history (text-only response)
|
||||
if response_text:
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
except Exception as e:
|
||||
# Rollback chat history on error
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
logging.error(f"OpenAI streaming error: {e}")
|
||||
# Fall back to simulated streaming
|
||||
response = self.chat(prompt, **kwargs)
|
||||
@@ -2513,4 +2644,3 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
response = self.chat(prompt, **kwargs)
|
||||
if response:
|
||||
yield response
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Per-Agent Concurrency Limiter for PraisonAI Agents.
|
||||
|
||||
Provides a registry-based approach to limit concurrent task execution
|
||||
per agent name. No Agent constructor param bloat — uses a global registry.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.agent.concurrency import get_concurrency_registry
|
||||
|
||||
registry = get_concurrency_registry()
|
||||
registry.set_limit("researcher", 2) # max 2 concurrent tasks
|
||||
|
||||
# In async code:
|
||||
async with registry.throttle("researcher"):
|
||||
await do_work()
|
||||
|
||||
# Or manual:
|
||||
await registry.acquire("researcher")
|
||||
try:
|
||||
await do_work()
|
||||
finally:
|
||||
registry.release("researcher")
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, Optional
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConcurrencyRegistry:
|
||||
"""Registry for per-agent concurrency limits.
|
||||
|
||||
Thread-safe. Each agent name maps to an asyncio.Semaphore.
|
||||
Limit of 0 means unlimited (no throttling).
|
||||
"""
|
||||
|
||||
def __init__(self, default_limit: int = 0):
|
||||
self._default_limit = default_limit
|
||||
self._limits: Dict[str, int] = {}
|
||||
self._semaphores: Dict[str, asyncio.Semaphore] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_limit(self, agent_name: str, max_concurrent: int) -> None:
|
||||
"""Set concurrency limit for an agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent identifier
|
||||
max_concurrent: Max concurrent tasks (0 = unlimited)
|
||||
"""
|
||||
with self._lock:
|
||||
self._limits[agent_name] = max_concurrent
|
||||
# Reset semaphore so next acquire creates a fresh one
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def get_limit(self, agent_name: str) -> int:
|
||||
"""Get concurrency limit for an agent."""
|
||||
with self._lock:
|
||||
return self._limits.get(agent_name, self._default_limit)
|
||||
|
||||
def remove_limit(self, agent_name: str) -> None:
|
||||
"""Remove concurrency limit for an agent (reverts to default)."""
|
||||
with self._lock:
|
||||
self._limits.pop(agent_name, None)
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def _get_semaphore(self, agent_name: str) -> Optional[asyncio.Semaphore]:
|
||||
"""Get or create semaphore for agent. Returns None if unlimited."""
|
||||
with self._lock:
|
||||
limit = self._limits.get(agent_name, self._default_limit)
|
||||
if limit <= 0:
|
||||
return None
|
||||
if agent_name not in self._semaphores:
|
||||
self._semaphores[agent_name] = asyncio.Semaphore(limit)
|
||||
return self._semaphores[agent_name]
|
||||
|
||||
async def acquire(self, agent_name: str) -> None:
|
||||
"""Acquire concurrency slot for agent. No-op if unlimited."""
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is not None:
|
||||
await sem.acquire()
|
||||
|
||||
def acquire_sync(self, agent_name: str) -> None:
|
||||
"""Synchronous acquire — for non-async code paths.
|
||||
|
||||
Prefer async acquire() when possible.
|
||||
If called while an event loop is already running in the current thread,
|
||||
this method raises RuntimeError to avoid deadlock.
|
||||
"""
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is None:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop — safe to create one
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(sem.acquire())
|
||||
finally:
|
||||
loop.close()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"acquire_sync('{agent_name}') cannot be called with a running event loop; "
|
||||
"use async acquire() in async contexts."
|
||||
)
|
||||
|
||||
def release(self, agent_name: str) -> None:
|
||||
"""Release concurrency slot for agent. No-op if unlimited."""
|
||||
with self._lock:
|
||||
sem = self._semaphores.get(agent_name)
|
||||
if sem is not None:
|
||||
try:
|
||||
sem.release()
|
||||
except ValueError:
|
||||
pass # Already fully released
|
||||
|
||||
@asynccontextmanager
|
||||
async def throttle(self, agent_name: str):
|
||||
"""Async context manager for throttled execution.
|
||||
|
||||
Usage:
|
||||
async with registry.throttle("agent_name"):
|
||||
await do_work()
|
||||
"""
|
||||
await self.acquire(agent_name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.release(agent_name)
|
||||
|
||||
|
||||
# Singleton
|
||||
_registry: Optional[ConcurrencyRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_concurrency_registry() -> ConcurrencyRegistry:
|
||||
"""Get the global concurrency registry singleton."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
with _registry_lock:
|
||||
if _registry is None:
|
||||
_registry = ConcurrencyRegistry()
|
||||
return _registry
|
||||
@@ -309,8 +309,8 @@ class ExecutionMixin:
|
||||
# ── Chat history & session linkage ──
|
||||
# Record prompt+response in chat_history so SessionStore/auto_save works
|
||||
if result is not None:
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": str(result)})
|
||||
self._append_to_chat_history({"role": "user", "content": prompt})
|
||||
self._append_to_chat_history({"role": "assistant", "content": str(result)})
|
||||
# Link managed session ID into SessionStore gateway_session_id
|
||||
if hasattr(self.backend, 'managed_session_id'):
|
||||
msid = self.backend.managed_session_id
|
||||
@@ -319,8 +319,14 @@ class ExecutionMixin:
|
||||
sid = getattr(self, 'auto_save', None) or getattr(self, '_session_id', None)
|
||||
if sid and hasattr(self._session_store, 'set_gateway_info'):
|
||||
self._session_store.set_gateway_info(sid, gateway_session_id=msid)
|
||||
except Exception:
|
||||
pass # Best-effort linkage
|
||||
except Exception as e:
|
||||
# Log session linkage failures for debugging
|
||||
logger.warning(
|
||||
"Session gateway linkage failed: %s",
|
||||
e,
|
||||
extra={"session_id": sid, "managed_session_id": msid},
|
||||
exc_info=True,
|
||||
)
|
||||
self._auto_save_session()
|
||||
|
||||
return result
|
||||
@@ -942,7 +948,7 @@ Write the complete compiled report:"""
|
||||
task_id = getattr(task, 'id', None)
|
||||
return await self.achat(prompt, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
|
||||
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
|
||||
"""Async version of execute_tool"""
|
||||
try:
|
||||
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
|
||||
@@ -958,9 +964,10 @@ Write the complete compiled report:"""
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "approval_error": True}
|
||||
|
||||
# Try to find the function in the agent's tools list first
|
||||
# Try to find the function in the override tools list first, then agent's tools list
|
||||
func = None
|
||||
for tool in self.tools:
|
||||
tools_to_search = tools_override if tools_override is not None else self.tools
|
||||
for tool in tools_to_search:
|
||||
if (callable(tool) and getattr(tool, '__name__', '') == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
@@ -175,6 +175,7 @@ class Handoff:
|
||||
# Class-level semaphore for concurrency control
|
||||
_semaphore: Optional[asyncio.Semaphore] = None
|
||||
_sync_semaphore: Optional[threading.Semaphore] = None
|
||||
_semaphore_lock: threading.Lock = threading.Lock() # Lock for semaphore initialization
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -461,9 +462,11 @@ class Handoff:
|
||||
Returns:
|
||||
HandoffResult with response or error
|
||||
"""
|
||||
# Initialize semaphore if needed
|
||||
# Initialize semaphore if needed (thread-safe)
|
||||
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
|
||||
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
|
||||
with Handoff._semaphore_lock: # Thread-safe initialization
|
||||
if Handoff._semaphore is None: # Double-check after acquiring lock
|
||||
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
|
||||
|
||||
start_time = time.time()
|
||||
kwargs = context or {}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Interrupt Controller - Cooperative cancellation for agent runs.
|
||||
|
||||
Provides thread-safe, cooperative cancellation mechanism for long-running agent
|
||||
operations. Follows protocol-driven design with zero overhead when not used.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Protocol
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["InterruptControllerProtocol", "InterruptController"]
|
||||
|
||||
|
||||
class InterruptControllerProtocol(Protocol):
|
||||
"""Protocol for interrupt controller extension point."""
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with optional reason."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear interrupt state."""
|
||||
...
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Check if interrupt was requested."""
|
||||
...
|
||||
|
||||
@property
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get interrupt reason if set."""
|
||||
...
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for interrupt and raise if set."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptController:
|
||||
"""Thread-safe cooperative cancellation for agent runs.
|
||||
|
||||
Provides a lightweight mechanism for requesting cancellation of agent
|
||||
operations. Uses threading.Event for thread safety and cooperative
|
||||
checking patterns.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
>>> controller = InterruptController()
|
||||
>>> # In another thread:
|
||||
>>> controller.request("user_cancel")
|
||||
>>> # In agent loop:
|
||||
>>> if controller.is_set():
|
||||
>>> return f"Cancelled: {controller.reason}"
|
||||
"""
|
||||
|
||||
_flag: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
|
||||
_reason: Optional[str] = field(default=None, init=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with a reason.
|
||||
|
||||
Args:
|
||||
reason: Human-readable reason for cancellation
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._flag.is_set():
|
||||
self._reason = reason
|
||||
self._flag.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cancellation request."""
|
||||
with self._lock:
|
||||
self._reason = None
|
||||
self._flag.clear()
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Check if cancellation has been requested.
|
||||
|
||||
Returns:
|
||||
True if cancellation was requested
|
||||
"""
|
||||
return self._flag.is_set()
|
||||
|
||||
@property
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get the reason for cancellation.
|
||||
|
||||
Returns:
|
||||
Reason string if cancelled, None otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for cancellation and raise if requested.
|
||||
|
||||
Raises:
|
||||
InterruptedError: If cancellation was requested
|
||||
"""
|
||||
if self.is_set():
|
||||
reason = self.reason or "unknown"
|
||||
raise InterruptedError(f"Operation cancelled: {reason}")
|
||||
@@ -8,6 +8,7 @@ for maintainability.
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
@@ -520,6 +521,37 @@ class MemoryMixin:
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-learning extraction failed: {e}")
|
||||
|
||||
def _maybe_emit_nudge(self, user_message: str) -> Optional[str]:
|
||||
"""Return a system note to append to the next user turn, or None.
|
||||
|
||||
Implements the nudge mechanism for self-improving agents.
|
||||
When nudge_interval > 0, this increments a turn counter and emits
|
||||
a nudge prompt every N turns to encourage knowledge persistence.
|
||||
"""
|
||||
if not self._learn_config or self._learn_config.nudge_interval <= 0:
|
||||
return None
|
||||
|
||||
# Initialize/increment turn counter
|
||||
self._turns_since_nudge = getattr(self, "_turns_since_nudge", 0) + 1
|
||||
|
||||
if self._turns_since_nudge < self._learn_config.nudge_interval:
|
||||
return None
|
||||
|
||||
# Reset counter and emit nudge
|
||||
self._turns_since_nudge = 0
|
||||
|
||||
# Optional: only nudge if agent has done meaningful work
|
||||
min_iters = getattr(self._learn_config, 'nudge_min_tool_iters', 3)
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
|
||||
return (
|
||||
"\n\n[System nudge] Review the recent conversation. If you discovered a "
|
||||
"non-trivial procedure or pattern, consider using available tools to "
|
||||
"persist this knowledge for future use. Skip if nothing noteworthy."
|
||||
)
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled.
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ import json
|
||||
import logging
|
||||
import asyncio
|
||||
import inspect
|
||||
import contextvars
|
||||
import concurrent.futures
|
||||
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
|
||||
from ..errors import ToolExecutionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -190,19 +194,32 @@ class ToolExecutionMixin:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
with with_injection_context(state):
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self._execute_tool_impl, function_name, arguments)
|
||||
try:
|
||||
result = future.result(timeout=tool_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
else:
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
# Use copy_context to preserve injection context in executor thread
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
def execute_with_context():
|
||||
with with_injection_context(state):
|
||||
return self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
|
||||
# Use reusable executor to prevent resource leaks
|
||||
if not hasattr(self, '_tool_executor'):
|
||||
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=2, thread_name_prefix=f"tool-{self.name}"
|
||||
)
|
||||
|
||||
future = self._tool_executor.submit(ctx.run, execute_with_context)
|
||||
try:
|
||||
result = future.result(timeout=tool_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
future.cancel()
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
else:
|
||||
with with_injection_context(state):
|
||||
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
|
||||
# Apply tool output truncation to prevent context overflow
|
||||
# Uses context manager budget if enabled, otherwise applies default limit
|
||||
@@ -290,8 +307,14 @@ class ToolExecutionMixin:
|
||||
_duration_ms = (_time.time() - _tool_start_time) * 1000
|
||||
_trace_emitter.tool_call_end(self.name, function_name, None, _duration_ms, str(e))
|
||||
|
||||
# Trigger OnError hook if needed (optional future step)
|
||||
raise
|
||||
# Gap 3a fix: Wrap exceptions in ToolExecutionError for better observability
|
||||
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
|
||||
raise ToolExecutionError(
|
||||
f"Tool '{function_name}' failed: {e}",
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
) from e
|
||||
|
||||
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Trigger AFTER_AGENT hook and return response."""
|
||||
@@ -317,7 +340,19 @@ class ToolExecutionMixin:
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
|
||||
# Periodic nudge (opt-in via LearnConfig(nudge_interval>0)).
|
||||
# Appends a system note to chat_history so it is visible on the NEXT
|
||||
# LLM call — encourages the agent to persist non-trivial procedures
|
||||
# as skills/memory. No-op when nudge_interval=0 (default).
|
||||
try:
|
||||
nudge = self._maybe_emit_nudge(prompt if isinstance(prompt, str) else str(prompt))
|
||||
if nudge and hasattr(self, "chat_history") and isinstance(self.chat_history, list):
|
||||
self._append_to_chat_history({"role": "system", "content": nudge.strip()})
|
||||
except Exception as e:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
@@ -344,7 +379,16 @@ class ToolExecutionMixin:
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
|
||||
# Periodic nudge (opt-in via LearnConfig(nudge_interval>0)).
|
||||
try:
|
||||
nudge = self._maybe_emit_nudge(prompt if isinstance(prompt, str) else str(prompt))
|
||||
if nudge and hasattr(self, "chat_history") and isinstance(self.chat_history, list):
|
||||
self._append_to_chat_history({"role": "system", "content": nudge.strip()})
|
||||
except Exception as e:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
|
||||
@@ -550,6 +594,73 @@ class ToolExecutionMixin:
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
return None, arguments
|
||||
|
||||
def _execute_tool_with_circuit_breaker(self, function_name, arguments):
|
||||
"""Execute tool with circuit breaker protection.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool to execute
|
||||
arguments: Arguments for the tool
|
||||
|
||||
Returns:
|
||||
Tool execution result or circuit breaker error
|
||||
"""
|
||||
# Import circuit breaker components first (lazy import for performance)
|
||||
try:
|
||||
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException
|
||||
except ImportError:
|
||||
# Circuit breaker not available - fallback to direct execution
|
||||
logging.debug("Circuit breaker not available, falling back to direct tool execution")
|
||||
return self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
try:
|
||||
|
||||
# Get or create circuit breaker for this tool
|
||||
breaker_name = f"tool_{function_name}"
|
||||
config = CircuitBreakerConfig(
|
||||
failure_threshold=5, # Open after 5 failures
|
||||
recovery_timeout=60.0, # Wait 60s before trying half-open
|
||||
timeout=30.0, # Tool call timeout
|
||||
graceful_degradation=True # Return error instead of raising exception
|
||||
)
|
||||
breaker = get_circuit_breaker(breaker_name, config)
|
||||
|
||||
# Execute tool through circuit breaker with failure detection wrapper
|
||||
def _tool_wrapper():
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
# Convert error dicts to exceptions so circuit breaker can detect failures
|
||||
# Don't treat approval/permission denials as circuit breaker failures
|
||||
if isinstance(result, dict) and result.get("error") and \
|
||||
not result.get("approval_denied") and \
|
||||
not result.get("permission_denied") and \
|
||||
not result.get("approval_error"):
|
||||
# Create a sentinel exception to register failure with circuit breaker
|
||||
class _ToolFailure(Exception):
|
||||
def __init__(self, error_dict):
|
||||
self.error_dict = error_dict
|
||||
super().__init__(error_dict.get("error", "Tool execution failed"))
|
||||
raise _ToolFailure(result)
|
||||
return result
|
||||
|
||||
try:
|
||||
return breaker.call(_tool_wrapper)
|
||||
except Exception as e:
|
||||
# Check if this is our sentinel exception
|
||||
if hasattr(e, 'error_dict'):
|
||||
return e.error_dict # Return the original error dict
|
||||
else:
|
||||
raise # Re-raise other exceptions
|
||||
|
||||
except CircuitBreakerException as e:
|
||||
# Circuit breaker is open - return error dict instead of raising
|
||||
logging.warning(f"Tool '{function_name}' circuit breaker open: {e}")
|
||||
return {
|
||||
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
|
||||
"circuit_open": True,
|
||||
"agent_name": getattr(self, "name", None),
|
||||
"session_id": getattr(self, "_session_id", None),
|
||||
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
|
||||
}
|
||||
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
@@ -716,12 +827,18 @@ class ToolExecutionMixin:
|
||||
|
||||
tracking_id = str(uuid.uuid4())[:8]
|
||||
task = asyncio.ensure_future(backend.request_approval(request))
|
||||
self._pending_approvals[tracking_id] = {
|
||||
"task": task,
|
||||
"function_name": function_name,
|
||||
"arguments": arguments,
|
||||
"request": request,
|
||||
}
|
||||
|
||||
# Make defensive copy to prevent TOCTOU mutations
|
||||
import copy
|
||||
frozen_args = copy.deepcopy(arguments)
|
||||
|
||||
async with self._approvals_lock:
|
||||
self._pending_approvals[tracking_id] = {
|
||||
"task": task,
|
||||
"function_name": function_name,
|
||||
"arguments": frozen_args,
|
||||
"request": request,
|
||||
}
|
||||
logging.info(f"Approval request submitted: {tracking_id} for {function_name}")
|
||||
return tracking_id
|
||||
|
||||
@@ -733,40 +850,63 @@ class ToolExecutionMixin:
|
||||
and their results included.
|
||||
"""
|
||||
results = {}
|
||||
completed_ids = []
|
||||
# Collect completed items under the lock, then execute tools outside the lock
|
||||
# to avoid holding it during potentially slow async tool execution.
|
||||
approved_items = []
|
||||
denied_items = []
|
||||
error_items = []
|
||||
|
||||
for tid, info in self._pending_approvals.items():
|
||||
task = info["task"]
|
||||
if task.done():
|
||||
completed_ids.append(tid)
|
||||
try:
|
||||
decision = task.result()
|
||||
if decision.approved:
|
||||
# Auto-execute the approved tool
|
||||
tool_result = await self.execute_tool_async(
|
||||
info["function_name"], info["arguments"],
|
||||
)
|
||||
results[tid] = {
|
||||
"status": "approved_and_executed",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
"result": tool_result,
|
||||
}
|
||||
else:
|
||||
results[tid] = {
|
||||
"status": "denied",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
}
|
||||
except Exception as e:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(e),
|
||||
}
|
||||
async with self._approvals_lock:
|
||||
completed_ids = []
|
||||
for tid, info in list(self._pending_approvals.items()):
|
||||
task = info["task"]
|
||||
if task.done():
|
||||
completed_ids.append(tid)
|
||||
try:
|
||||
decision = task.result()
|
||||
if decision.approved:
|
||||
approved_items.append((tid, info, decision))
|
||||
else:
|
||||
denied_items.append((tid, info, decision))
|
||||
except Exception as e:
|
||||
error_items.append((tid, info, e))
|
||||
|
||||
for tid in completed_ids:
|
||||
del self._pending_approvals[tid]
|
||||
# Remove completed entries while still holding the lock
|
||||
for tid in completed_ids:
|
||||
del self._pending_approvals[tid]
|
||||
|
||||
# Execute approved tools outside the lock to avoid long lock hold
|
||||
for tid, info, decision in approved_items:
|
||||
try:
|
||||
tool_result = await self.execute_tool_async(
|
||||
info["function_name"], info["arguments"],
|
||||
)
|
||||
results[tid] = {
|
||||
"status": "approved_and_executed",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
"result": tool_result,
|
||||
}
|
||||
except Exception as e:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
for tid, info, decision in denied_items:
|
||||
results[tid] = {
|
||||
"status": "denied",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
for tid, info, exc in error_items:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Unified Execution Mixin - implements Gap 1 from Issue #1392.
|
||||
|
||||
This module consolidates sync/async execution paths into a single async-first
|
||||
implementation with a thin sync bridge, eliminating code duplication between
|
||||
chat()/achat() and execute_tool()/execute_tool_async().
|
||||
|
||||
Architecture:
|
||||
- Single async core: _unified_chat_impl() contains all business logic
|
||||
- Sync bridge: chat() delegates to asyncio.run() or run_coroutine_threadsafe
|
||||
- Maintains full backward compatibility for public APIs
|
||||
- Zero performance regression (sync calls still work efficiently)
|
||||
|
||||
Design principles:
|
||||
- Protocol-driven: follows existing chat_mixin patterns
|
||||
- DRY: eliminates duplicate retrieval, hooks, guardrail, tool logic
|
||||
- Agent-centric: preserves all existing Agent functionality
|
||||
- Async-safe: handles event loop management correctly
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedExecutionMixin:
|
||||
"""
|
||||
Mixin providing unified sync/async execution for Agent class.
|
||||
|
||||
This replaces the duplicated logic between chat/achat and execute_tool/execute_tool_async
|
||||
with a single async-first implementation plus sync bridge.
|
||||
"""
|
||||
|
||||
async def _unified_chat_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[Any] = None,
|
||||
output_pydantic: Optional[Any] = None,
|
||||
reasoning_steps: bool = False,
|
||||
stream: Optional[bool] = None,
|
||||
task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
force_retrieval: bool = False,
|
||||
skip_retrieval: bool = False,
|
||||
attachments: Optional[List[str]] = None,
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Unified async implementation for chat functionality.
|
||||
|
||||
Contains all business logic that was previously duplicated between
|
||||
_chat_impl and _achat_impl. Both sync and async entry points delegate here.
|
||||
|
||||
This is the single source of truth for:
|
||||
- Retrieval logic and context building
|
||||
- Hook dispatch (BEFORE_AGENT, AFTER_AGENT)
|
||||
- Guardrail application
|
||||
- Tool invocation with parallel execution support
|
||||
- Memory storage and context assembly
|
||||
- Response template application
|
||||
- Session management and persistence
|
||||
"""
|
||||
from ..hooks import HookEvent, BeforeAgentInput
|
||||
from ..trace.context_events import get_context_emitter
|
||||
import time
|
||||
import os
|
||||
|
||||
# Emit context trace event (zero overhead when not set)
|
||||
_trace_emitter = get_context_emitter()
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
return await self._chat_via_cli_backend(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
force_retrieval=force_retrieval,
|
||||
skip_retrieval=skip_retrieval,
|
||||
attachments=attachments,
|
||||
tool_choice=tool_choice
|
||||
)
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if hasattr(self, '_rate_limiter') and self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
|
||||
# Process ephemeral attachments (DRY - builds multimodal prompt)
|
||||
# IMPORTANT: Original text 'prompt' is stored in history, attachments are NOT
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Apply response template if configured
|
||||
effective_template = getattr(self, 'response_template', None) or getattr(self, '_output_template', None)
|
||||
if effective_template:
|
||||
template_instruction = f"\n\nIMPORTANT: Format your response according to this template:\n{effective_template}"
|
||||
if isinstance(llm_prompt, str):
|
||||
llm_prompt = llm_prompt + template_instruction
|
||||
elif isinstance(llm_prompt, list):
|
||||
# For multimodal prompts, append to the last text content
|
||||
for i in range(len(llm_prompt) - 1, -1, -1):
|
||||
if isinstance(llm_prompt[i], dict) and llm_prompt[i].get('type') == 'text':
|
||||
llm_prompt[i]['text'] = llm_prompt[i]['text'] + template_instruction
|
||||
break
|
||||
|
||||
# Initialize DB session on first chat (lazy)
|
||||
if hasattr(self, '_init_db_session'):
|
||||
self._init_db_session()
|
||||
|
||||
# Initialize session store for JSON-based persistence (lazy)
|
||||
if hasattr(self, '_init_session_store'):
|
||||
self._init_session_store()
|
||||
|
||||
# Start a new run for this chat turn
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
if hasattr(self, '_start_run'):
|
||||
self._start_run(prompt_str)
|
||||
|
||||
# Trigger BEFORE_AGENT hook (unified async/sync handling)
|
||||
before_agent_input = BeforeAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_AGENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
prompt=prompt_str,
|
||||
conversation_history=getattr(self, 'chat_history', []),
|
||||
tools_available=[
|
||||
t.__name__ if hasattr(t, '__name__') else str(t)
|
||||
for t in (tools or getattr(self, 'tools', []))
|
||||
]
|
||||
)
|
||||
|
||||
# Execute hooks in async context (handles both sync and async hooks)
|
||||
hook_results = await self._hook_runner.execute(HookEvent.BEFORE_AGENT, before_agent_input)
|
||||
if self._hook_runner.is_blocked(hook_results):
|
||||
logging.warning(f"Agent {self.name} execution blocked by BEFORE_AGENT hook")
|
||||
return None
|
||||
|
||||
# Update prompt if modified by hooks
|
||||
for res in hook_results:
|
||||
if res.output and res.output.modified_data and "prompt" in res.output.modified_data:
|
||||
prompt = res.output.modified_data["prompt"]
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
if hasattr(self, '_final_display_shown'):
|
||||
self._final_display_shown = False
|
||||
|
||||
# Unified retrieval handling with policy-based decision (DRY)
|
||||
if getattr(self, '_knowledge_sources', None) or getattr(self, 'knowledge', None) is not None:
|
||||
if not getattr(self, '_knowledge_processed', False):
|
||||
if hasattr(self, '_ensure_knowledge_processed'):
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
# Determine if we should retrieve based on policy
|
||||
should_retrieve = False
|
||||
if getattr(self, '_retrieval_config', None) is not None:
|
||||
should_retrieve = self._retrieval_config.should_retrieve(
|
||||
prompt_str,
|
||||
force=force_retrieval,
|
||||
skip=skip_retrieval
|
||||
)
|
||||
elif not skip_retrieval:
|
||||
# No config but knowledge exists - retrieve by default unless skipped
|
||||
should_retrieve = True if force_retrieval else (getattr(self, 'knowledge', None) is not None)
|
||||
|
||||
if should_retrieve and getattr(self, 'knowledge', None):
|
||||
# Use unified retrieval path with token-aware context building
|
||||
if hasattr(self, '_get_knowledge_context'):
|
||||
knowledge_context, _ = self._get_knowledge_context(
|
||||
prompt_str,
|
||||
use_rag=True # Use RAG pipeline for token-aware context
|
||||
)
|
||||
if knowledge_context:
|
||||
if isinstance(llm_prompt, str):
|
||||
llm_prompt = f"{llm_prompt}\n\nKnowledge: {knowledge_context}"
|
||||
# For multimodal prompts, we could append to text content here
|
||||
|
||||
# Use agent's stream setting if not explicitly provided
|
||||
if stream is None:
|
||||
stream = getattr(self, 'stream', False)
|
||||
|
||||
reasoning_steps = reasoning_steps or getattr(self, 'reasoning_steps', False)
|
||||
|
||||
# Default to self.tools if tools argument is None
|
||||
if tools is None:
|
||||
tools = getattr(self, 'tools', [])
|
||||
|
||||
# Ensure LLM client is available (fallback from llm_instance to llm)
|
||||
llm_client = getattr(self, 'llm_instance', None) or getattr(self, 'llm', None)
|
||||
if llm_client is None:
|
||||
raise RuntimeError("No LLM client available. Agent must have either llm_instance or llm attribute.")
|
||||
|
||||
# Call the LLM using async method (supports both custom and standard LLMs)
|
||||
if getattr(self, '_using_custom_llm', False):
|
||||
# Async custom LLM path
|
||||
response_text = await llm_client.get_response_async(
|
||||
prompt=llm_prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=getattr(self, 'chat_history', []),
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
)
|
||||
else:
|
||||
# Standard LiteLLM path - delegate to existing LLM class
|
||||
response_text = await llm_client.get_response_async(
|
||||
prompt=llm_prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=getattr(self, 'chat_history', []),
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
agent_name=getattr(self, 'name', ''),
|
||||
agent_role=getattr(self, 'role', ''),
|
||||
original_prompt=prompt_str,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
)
|
||||
|
||||
# Store response in memory if enabled
|
||||
if hasattr(self, '_memory_instance') and self._memory_instance:
|
||||
try:
|
||||
# Store the interaction in memory
|
||||
self._memory_instance.store_short_term(
|
||||
f"User: {prompt_str}\nAssistant: {response_text or ''}",
|
||||
metadata={"agent_id": getattr(self, 'agent_id', self.name)}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store interaction in memory: {e}")
|
||||
|
||||
# Apply guardrails if configured (unified path)
|
||||
if hasattr(self, 'guardrail') and self.guardrail and response_text:
|
||||
try:
|
||||
guardrail_result = await self._apply_guardrails_async(response_text)
|
||||
if guardrail_result.blocked:
|
||||
logging.warning(f"Response blocked by guardrail: {guardrail_result.reason}")
|
||||
return guardrail_result.alternative_response or "Response blocked by content policy."
|
||||
elif guardrail_result.modified_response:
|
||||
response_text = guardrail_result.modified_response
|
||||
except Exception as e:
|
||||
logging.warning(f"Guardrail application failed: {e}")
|
||||
|
||||
# Trigger AFTER_AGENT hook
|
||||
# (Implementation similar to BEFORE_AGENT hook)
|
||||
|
||||
return response_text
|
||||
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
async def _apply_guardrails_async(self, response_text: str):
|
||||
"""Apply guardrails in async context. Placeholder for guardrail logic."""
|
||||
# This would contain the actual guardrail logic
|
||||
# For now, return a simple result structure
|
||||
class GuardrailResult:
|
||||
def __init__(self):
|
||||
self.blocked = False
|
||||
self.reason = None
|
||||
self.modified_response = None
|
||||
self.alternative_response = None
|
||||
|
||||
return GuardrailResult()
|
||||
|
||||
def _run_async_in_sync_context(self, coro):
|
||||
"""
|
||||
Run async coroutine in sync context with proper event loop handling.
|
||||
|
||||
Handles the common cases:
|
||||
1. No event loop exists - use asyncio.run()
|
||||
2. Event loop exists on main thread - use dedicated thread with new loop
|
||||
3. Event loop exists on worker thread - create new event loop
|
||||
"""
|
||||
try:
|
||||
# Try to get the current event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No event loop - safe to use asyncio.run()
|
||||
return asyncio.run(coro)
|
||||
|
||||
# Event loop exists - avoid deadlock by running in dedicated thread
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
# Create new event loop in dedicated thread
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro)
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result(timeout=300) # 5 minute timeout
|
||||
|
||||
def unified_chat(self, *args, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Sync entry point for unified chat - delegates to async implementation.
|
||||
|
||||
This replaces the duplicated _chat_impl logic by using the unified
|
||||
async core with proper event loop bridging.
|
||||
"""
|
||||
return self._run_async_in_sync_context(
|
||||
self._unified_chat_impl(*args, **kwargs)
|
||||
)
|
||||
|
||||
async def unified_achat(self, *args, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Async entry point for unified chat - direct call to async implementation.
|
||||
|
||||
This replaces the duplicated _achat_impl logic by using the unified
|
||||
async core directly.
|
||||
"""
|
||||
return await self._unified_chat_impl(*args, **kwargs)
|
||||
|
||||
async def _unified_tool_execution(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
tool_call_id: Optional[str] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Unified async tool execution implementation.
|
||||
|
||||
Contains all business logic that was previously duplicated between
|
||||
execute_tool and execute_tool_async. Both sync and async entry points delegate here.
|
||||
"""
|
||||
# Delegate to the existing async tool execution method on self
|
||||
# This would contain the unified tool execution logic in a full implementation
|
||||
return await self.execute_tool_async(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
tool_call_id=tool_call_id
|
||||
)
|
||||
|
||||
def unified_execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Sync entry point for unified tool execution.
|
||||
"""
|
||||
return self._run_async_in_sync_context(
|
||||
self._unified_tool_execution(function_name, arguments, tool_call_id)
|
||||
)
|
||||
|
||||
async def unified_execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Async entry point for unified tool execution.
|
||||
"""
|
||||
return await self._unified_tool_execution(function_name, arguments, tool_call_id)
|
||||
@@ -2,8 +2,9 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Any, Dict, Optional, List
|
||||
from typing import Any, Dict, Optional, List, Tuple
|
||||
from ..main import display_error, TaskOutput
|
||||
from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
@@ -18,6 +19,12 @@ try:
|
||||
except ImportError:
|
||||
_token_collector = None
|
||||
|
||||
# Import async utility for hot-path usage
|
||||
try:
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
except ImportError:
|
||||
run_coroutine_safely = None
|
||||
|
||||
# Task status constants
|
||||
class TaskStatus(Enum):
|
||||
"""Enumeration for task status values to ensure consistency"""
|
||||
@@ -30,12 +37,101 @@ class TaskStatus(Enum):
|
||||
# Set up logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Global variables for managing the shared servers with thread-safety
|
||||
import threading
|
||||
_agents_server_lock = threading.Lock() # Protect all global server state mutations
|
||||
_agents_server_started = {} # Dict of port -> started boolean
|
||||
_agents_registered_endpoints = {} # Dict of port -> Dict of path -> endpoint_id
|
||||
_agents_shared_apps = {} # Dict of port -> FastAPI app
|
||||
# Agent server registry for thread-safe server management
|
||||
|
||||
|
||||
class _AgentServerRegistry:
|
||||
"""Encapsulates all shared HTTP server state with proper synchronization."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._started: Dict[int, bool] = {}
|
||||
self._endpoints: Dict[int, Dict[str, str]] = {}
|
||||
self._apps: Dict[int, Any] = {} # FastAPI apps
|
||||
self._ready_events: Dict[int, threading.Event] = {}
|
||||
|
||||
def get_or_create_app(self, port: int, title: str = "AgentTeam API") -> Tuple[Any, bool]:
|
||||
"""Thread-safe app creation. Returns (app, is_new)."""
|
||||
with self._lock:
|
||||
if port not in self._apps:
|
||||
# Lazy import to avoid optional dependency at module level
|
||||
from fastapi import FastAPI
|
||||
self._apps[port] = FastAPI(title=title)
|
||||
self._endpoints[port] = {}
|
||||
self._ready_events[port] = threading.Event()
|
||||
return self._apps[port], True
|
||||
return self._apps[port], False
|
||||
|
||||
def register_route(self, port: int, path: str, endpoint_id: str = "registered") -> None:
|
||||
"""Thread-safe route registration tracking."""
|
||||
with self._lock:
|
||||
if port not in self._endpoints:
|
||||
self._endpoints[port] = {}
|
||||
self._endpoints[port][path] = endpoint_id
|
||||
|
||||
def reserve_route(self, port: int, path: str, endpoint_id: str) -> tuple[str, Optional[str]]:
|
||||
"""Atomically reserve and register a route.
|
||||
|
||||
Returns:
|
||||
tuple[str, Optional[str]]: (final_path, original_path_if_collided).
|
||||
If there is no collision, final_path equals the requested path and
|
||||
original_path_if_collided is None.
|
||||
"""
|
||||
with self._lock:
|
||||
if port not in self._endpoints:
|
||||
self._endpoints[port] = {}
|
||||
|
||||
original_path = path
|
||||
while path in self._endpoints[port]:
|
||||
path = f"{original_path}_{str(uuid.uuid4())[:6]}"
|
||||
|
||||
self._endpoints[port][path] = endpoint_id
|
||||
return path, (original_path if path != original_path else None)
|
||||
|
||||
def list_routes(self, port: int) -> List[str]:
|
||||
"""Return a snapshot list of registered routes for a port."""
|
||||
with self._lock:
|
||||
return list(self._endpoints.get(port, {}).keys())
|
||||
|
||||
def has_route(self, port: int, path: str) -> bool:
|
||||
"""Thread-safe check whether a path is registered on a port."""
|
||||
with self._lock:
|
||||
return path in self._endpoints.get(port, {})
|
||||
|
||||
def is_server_started(self, port: int) -> bool:
|
||||
"""Check if server is started for this port."""
|
||||
with self._lock:
|
||||
return self._started.get(port, False)
|
||||
|
||||
def start_server_if_needed(self, port: int, host: str = "0.0.0.0", **kwargs) -> bool: # noqa: S104
|
||||
"""Start server with proper readiness signaling. Returns True if server was started."""
|
||||
with self._lock:
|
||||
if self._started.get(port, False):
|
||||
return False # Already started
|
||||
self._started[port] = True
|
||||
app = self._apps.get(port)
|
||||
|
||||
if not app:
|
||||
raise ValueError(f"No app registered for port {port}")
|
||||
|
||||
ready_event = self._ready_events[port]
|
||||
|
||||
def run_server():
|
||||
import uvicorn
|
||||
# Remove hardcoded log_level to avoid conflict with kwargs
|
||||
config = uvicorn.Config(app, host=host, port=port, **kwargs)
|
||||
server = uvicorn.Server(config)
|
||||
ready_event.set() # Signal readiness
|
||||
server.run()
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
|
||||
return True
|
||||
|
||||
|
||||
# Module level — single registry instance
|
||||
_server_registry = _AgentServerRegistry()
|
||||
|
||||
def encode_file_to_base64(file_path: str) -> str:
|
||||
"""Base64-encode a file."""
|
||||
@@ -914,31 +1010,83 @@ class AgentTeam:
|
||||
logger.info(f"Task with ID {task_id} is already completed")
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
while task.status != "completed" and retries < self.max_retries:
|
||||
while task.status != "completed" and retries < task_max:
|
||||
logger.debug(f"Attempt {retries+1} for task {task_id}")
|
||||
if task.status in ["not started", "in progress"]:
|
||||
task_output = await self.aexecute_task(task_id)
|
||||
if task_output and self.completion_checker(task, task_output.raw):
|
||||
# Run guardrail validation BEFORE marking task complete
|
||||
if task._guardrail_fn:
|
||||
try:
|
||||
guardrail_result = task._process_guardrail(task_output)
|
||||
if not guardrail_result.success:
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {task.max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
retries += 1
|
||||
continue # Actually retry the task
|
||||
|
||||
# If guardrail passed and returned a modified result
|
||||
if guardrail_result.result is not None:
|
||||
if isinstance(guardrail_result.result, str):
|
||||
# Update the task output with the modified result
|
||||
task_output.raw = guardrail_result.result
|
||||
# Clear structured fields to avoid stale cache
|
||||
if hasattr(task_output, 'json_dict'):
|
||||
task_output.json_dict = None
|
||||
if hasattr(task_output, 'pydantic'):
|
||||
task_output.pydantic = None
|
||||
task.result = task_output
|
||||
elif hasattr(guardrail_result.result, 'raw'):
|
||||
# Replace with the new task output
|
||||
task_output = guardrail_result.result
|
||||
task.result = task_output
|
||||
|
||||
logger.info(f"Task {task_id}: Guardrail validation passed")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
|
||||
# Handle guardrail failure with retry logic
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
|
||||
f"Last error: {e}"
|
||||
) from e
|
||||
task.retry_count += 1
|
||||
task.status = "in progress"
|
||||
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
try:
|
||||
# Use the new sync wrapper to avoid pending coroutine issues
|
||||
task.execute_callback_sync(task_output)
|
||||
await task.execute_callback(task_output)
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing memory callback for task {task_id}: {e}")
|
||||
logger.exception(e)
|
||||
# Respect task failure policies - re-raise if configured
|
||||
if hasattr(task, 'fail_on_callback_error') and task.fail_on_callback_error:
|
||||
raise
|
||||
if hasattr(task, 'fail_on_memory_error') and task.fail_on_memory_error:
|
||||
raise
|
||||
|
||||
# Run task callback if exists
|
||||
if task.callback:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(task.callback):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(task.callback(task_output))
|
||||
except RuntimeError:
|
||||
# No event loop running, create new one
|
||||
asyncio.run(task.callback(task_output))
|
||||
if run_coroutine_safely:
|
||||
run_coroutine_safely(task.callback(task_output))
|
||||
else:
|
||||
logger.warning("run_coroutine_safely not available, skipping async callback")
|
||||
else:
|
||||
task.callback(task_output)
|
||||
except Exception as e:
|
||||
@@ -952,7 +1100,11 @@ class AgentTeam:
|
||||
task.status = "in progress"
|
||||
if self.verbose >= 1:
|
||||
logger.info(f"Task {task_id} not completed, retrying")
|
||||
await asyncio.sleep(1)
|
||||
# Use task's retry policy instead of hardcoded sleep (with cap)
|
||||
delay = getattr(task, 'retry_delay', 1)
|
||||
max_delay = getattr(task, 'max_retry_delay', 300) # 5 min cap
|
||||
actual_delay = min(delay * (2 ** retries), max_delay)
|
||||
await asyncio.sleep(actual_delay)
|
||||
retries += 1
|
||||
else:
|
||||
if task.status == "failed":
|
||||
@@ -962,8 +1114,9 @@ class AgentTeam:
|
||||
logger.info("Invalid Task status")
|
||||
break
|
||||
|
||||
if retries == self.max_retries and task.status != "completed":
|
||||
logger.info(f"Task {task_id} failed after {self.max_retries} retries.")
|
||||
if retries == task_max and task.status != "completed":
|
||||
task.status = "failed" # Set failed status to match sync behavior
|
||||
logger.info(f"Task {task_id} failed after {task_max} retries.")
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
@@ -1145,12 +1298,61 @@ class AgentTeam:
|
||||
if self.variables and not getattr(task, 'variables', None):
|
||||
task.variables = self.variables
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
while task.status != "completed" and retries < self.max_retries:
|
||||
while task.status != "completed" and retries < task_max:
|
||||
logger.debug(f"Attempt {retries+1} for task {task_id}")
|
||||
if task.status in ["not started", "in progress"]:
|
||||
task_output = self.execute_task(task_id)
|
||||
if task_output and self.completion_checker(task, task_output.raw):
|
||||
# Add guardrail validation (matches arun_task logic)
|
||||
if task._guardrail_fn:
|
||||
try:
|
||||
guardrail_result = task._process_guardrail(task_output)
|
||||
if not guardrail_result.success:
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {task.max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
# If guardrail passed and returned a modified result
|
||||
if guardrail_result.result is not None:
|
||||
if isinstance(guardrail_result.result, str):
|
||||
# Update the task output with the modified result
|
||||
task_output.raw = guardrail_result.result
|
||||
# Clear structured fields to avoid stale cache
|
||||
if hasattr(task_output, 'json_dict'):
|
||||
task_output.json_dict = None
|
||||
if hasattr(task_output, 'pydantic'):
|
||||
task_output.pydantic = None
|
||||
task.result = task_output
|
||||
elif hasattr(guardrail_result.result, 'raw'):
|
||||
# Replace with the new task output
|
||||
task_output = guardrail_result.result
|
||||
task.result = task_output
|
||||
|
||||
logger.info(f"Task {task_id}: Guardrail validation passed")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
|
||||
# Handle guardrail failure with retry logic
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
|
||||
f"Last error: {e}"
|
||||
) from e
|
||||
task.retry_count += 1
|
||||
task.status = "in progress"
|
||||
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
try:
|
||||
@@ -1164,12 +1366,10 @@ class AgentTeam:
|
||||
if task.callback:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(task.callback):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(task.callback(task_output))
|
||||
except RuntimeError:
|
||||
# No event loop running, create new one
|
||||
asyncio.run(task.callback(task_output))
|
||||
if run_coroutine_safely:
|
||||
run_coroutine_safely(task.callback(task_output))
|
||||
else:
|
||||
logger.warning("run_coroutine_safely not available, skipping async callback")
|
||||
else:
|
||||
task.callback(task_output)
|
||||
except Exception as e:
|
||||
@@ -1191,7 +1391,11 @@ class AgentTeam:
|
||||
task.status = "in progress"
|
||||
if self.verbose >= 1:
|
||||
logger.info(f"Task {task_id} not completed, retrying")
|
||||
time.sleep(1)
|
||||
# Use task's retry policy instead of hardcoded sleep (with cap)
|
||||
delay = getattr(task, 'retry_delay', 1)
|
||||
max_delay = getattr(task, 'max_retry_delay', 300) # 5 min cap
|
||||
actual_delay = min(delay * (2 ** retries), max_delay)
|
||||
time.sleep(actual_delay)
|
||||
retries += 1
|
||||
else:
|
||||
if task.status == "failed":
|
||||
@@ -1201,8 +1405,9 @@ class AgentTeam:
|
||||
logger.info("Invalid Task status")
|
||||
break
|
||||
|
||||
if retries == self.max_retries and task.status != "completed":
|
||||
logger.info(f"Task {task_id} failed after {self.max_retries} retries.")
|
||||
if retries == task_max and task.status != "completed":
|
||||
task.status = "failed" # Set failed status
|
||||
logger.info(f"Task {task_id} failed after {task_max} retries.")
|
||||
|
||||
def run_all_tasks(self):
|
||||
"""Synchronous version of run_all_tasks method"""
|
||||
@@ -1325,56 +1530,103 @@ class AgentTeam:
|
||||
))
|
||||
console.print()
|
||||
|
||||
# Execute tasks with verbose output
|
||||
# Use callbacks for verbose display while maintaining process orchestration
|
||||
total_agents = len(self.agents)
|
||||
workflow_start_time = time_module.time()
|
||||
task_times = {}
|
||||
task_idx = {}
|
||||
|
||||
for idx, (task_id, task) in enumerate(self.tasks.items(), 1):
|
||||
agent = task.agent
|
||||
agent_name = agent.display_name if agent else "Unknown"
|
||||
agent_model = getattr(agent, 'llm', 'gpt-4o-mini') if agent else "unknown"
|
||||
|
||||
# Show agent task panel with model info
|
||||
task_desc = task.description[:100] + "..." if len(task.description) > 100 else task.description
|
||||
panel_content = f"[bold {PRAISON_COLORS['task_text']}]📋 Task:[/] {task_desc}\n"
|
||||
panel_content += f"[dim]🤖 Model: {agent_model}[/dim]"
|
||||
console.print(Panel.fit(
|
||||
panel_content,
|
||||
title=f"[bold]Agent [{idx}/{total_agents}]: {agent_name}[/]",
|
||||
border_style=PRAISON_COLORS["task"]
|
||||
))
|
||||
|
||||
# Execute with timing and status
|
||||
start_time = time_module.time()
|
||||
|
||||
# Show working spinner
|
||||
with console.status(
|
||||
f"[bold yellow]Working...[/] {agent_name} generating response...",
|
||||
spinner="dots",
|
||||
spinner_style="yellow"
|
||||
):
|
||||
# Run the task
|
||||
if self.planning:
|
||||
self._run_with_planning()
|
||||
break # Planning mode handles all tasks
|
||||
else:
|
||||
self.run_task(task_id)
|
||||
|
||||
elapsed = time_module.time() - start_time
|
||||
|
||||
# Show response panel - FULL response, no truncation
|
||||
result = self.get_task_result(task_id)
|
||||
if result:
|
||||
response_text = str(result.raw)
|
||||
# No truncation - show full response in verbose mode
|
||||
from rich.markdown import Markdown
|
||||
console.print(Panel(
|
||||
Markdown(response_text),
|
||||
title=f"[bold]Agent [{idx}/{total_agents}] Complete ({elapsed:.1f}s)[/]",
|
||||
border_style=PRAISON_COLORS["response"],
|
||||
padding=(1, 2)
|
||||
# Set up index mapping for tasks
|
||||
for idx, task_id in enumerate(self.tasks.keys(), 1):
|
||||
task_idx[task_id] = idx
|
||||
|
||||
# Define callbacks to display progress while using proper orchestration
|
||||
def verbose_task_start_callback(task, task_id):
|
||||
try:
|
||||
agent = task.agent
|
||||
agent_name = agent.display_name if agent else "Unknown"
|
||||
agent_model = getattr(agent, 'llm', 'gpt-4o-mini') if agent else "unknown"
|
||||
idx = task_idx.get(task_id, 0)
|
||||
|
||||
# Show agent task panel with model info
|
||||
task_desc = task.description[:100] + "..." if len(task.description) > 100 else task.description
|
||||
panel_content = f"[bold {PRAISON_COLORS['task_text']}]📋 Task:[/] {task_desc}\n"
|
||||
panel_content += f"[dim]🤖 Model: {agent_model}[/dim]"
|
||||
console.print(Panel.fit(
|
||||
panel_content,
|
||||
title=f"[bold]Agent [{idx}/{total_agents}]: {agent_name}[/]",
|
||||
border_style=PRAISON_COLORS["task"]
|
||||
))
|
||||
console.print()
|
||||
|
||||
# Store start time for this task
|
||||
task_times[task_id] = time_module.time()
|
||||
|
||||
# Show working spinner
|
||||
console.print(f"[bold yellow]Working...[/] {agent_name} generating response...")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
|
||||
def verbose_task_complete_callback(task, task_output):
|
||||
try:
|
||||
task_id = getattr(task, 'id', 'unknown')
|
||||
start_time = task_times.get(task_id, time_module.time())
|
||||
elapsed = time_module.time() - start_time
|
||||
idx = task_idx.get(task_id, 0)
|
||||
|
||||
# Show response panel - FULL response, no truncation
|
||||
if task_output:
|
||||
response_text = str(task_output.raw)
|
||||
# No truncation - show full response in verbose mode
|
||||
from rich.markdown import Markdown
|
||||
console.print(Panel(
|
||||
Markdown(response_text),
|
||||
title=f"[bold]Agent [{idx}/{total_agents}] Complete ({elapsed:.1f}s)[/]",
|
||||
border_style=PRAISON_COLORS["response"],
|
||||
padding=(1, 2)
|
||||
))
|
||||
console.print()
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
|
||||
# Set callbacks for verbose display (compose with existing callbacks)
|
||||
original_on_task_start = self.on_task_start
|
||||
original_on_task_complete = self.on_task_complete
|
||||
|
||||
def composed_on_task_start(task):
|
||||
try:
|
||||
verbose_task_start_callback(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
if original_on_task_start:
|
||||
try:
|
||||
original_on_task_start(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task start callback: {e}")
|
||||
|
||||
def composed_on_task_complete(task):
|
||||
try:
|
||||
verbose_task_complete_callback(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
if original_on_task_complete:
|
||||
try:
|
||||
original_on_task_complete(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task complete callback: {e}")
|
||||
|
||||
self.on_task_start = composed_on_task_start
|
||||
self.on_task_complete = composed_on_task_complete
|
||||
|
||||
# Use proper process orchestration with verbose callbacks
|
||||
try:
|
||||
if self.planning:
|
||||
self._run_with_planning()
|
||||
else:
|
||||
self.run_all_tasks()
|
||||
finally:
|
||||
# Restore original callbacks
|
||||
self.on_task_start = original_on_task_start
|
||||
self.on_task_complete = original_on_task_complete
|
||||
|
||||
# Workflow summary panel
|
||||
total_elapsed = time_module.time() - workflow_start_time
|
||||
@@ -1643,7 +1895,7 @@ class AgentTeam:
|
||||
None
|
||||
"""
|
||||
if protocol == "http":
|
||||
global _agents_server_started, _agents_registered_endpoints, _agents_shared_apps
|
||||
# Use centralized server registry
|
||||
|
||||
if not self.agents:
|
||||
logging.warning("No agents to launch for HTTP mode. Add agents to the Agents instance first.")
|
||||
@@ -1674,58 +1926,43 @@ class AgentTeam:
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
# Thread-safe initialization of port-specific collections
|
||||
with _agents_server_lock:
|
||||
# Initialize port-specific collections if needed
|
||||
if port not in _agents_registered_endpoints:
|
||||
_agents_registered_endpoints[port] = {}
|
||||
|
||||
# Initialize shared FastAPI app if not already created for this port
|
||||
if _agents_shared_apps.get(port) is None:
|
||||
_agents_shared_apps[port] = FastAPI(
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
description="API for interacting with multiple PraisonAI Agents"
|
||||
)
|
||||
|
||||
# Thread-safe initialization of FastAPI app
|
||||
app, is_new = _server_registry.get_or_create_app(
|
||||
port, f"PraisonAI Agents API (Port {port})"
|
||||
)
|
||||
|
||||
if is_new:
|
||||
# Add a root endpoint with a welcome message
|
||||
@_agents_shared_apps[port].get("/")
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
|
||||
"endpoints": list(_agents_registered_endpoints[port].keys())
|
||||
"endpoints": _server_registry.list_routes(port)
|
||||
}
|
||||
|
||||
# Add healthcheck endpoint
|
||||
@_agents_shared_apps[port].get("/health")
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {
|
||||
"status": "ok",
|
||||
"endpoints": list(_agents_registered_endpoints[port].keys())
|
||||
"endpoints": _server_registry.list_routes(port)
|
||||
}
|
||||
|
||||
# Normalize path to ensure it starts with /
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
|
||||
# Thread-safe path registration
|
||||
with _agents_server_lock:
|
||||
# Check if path is already registered for this port
|
||||
if path in _agents_registered_endpoints[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
|
||||
instance_id = str(uuid.uuid4())[:6]
|
||||
path = f"{path}_{instance_id}"
|
||||
logging.warning(f"Using '{path}' instead of '{original_path}'")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Generate a unique ID for this agent group's endpoint
|
||||
endpoint_id = str(uuid.uuid4())
|
||||
_agents_registered_endpoints[port][path] = endpoint_id
|
||||
# Generate a unique ID for this agent group's endpoint and reserve route atomically
|
||||
endpoint_id = str(uuid.uuid4())
|
||||
path, original_path = _server_registry.reserve_route(port, path, endpoint_id)
|
||||
if original_path is not None:
|
||||
logging.warning(f"Path '{original_path}' is already registered on port {port}. Please use a different path.")
|
||||
print(f"⚠️ Warning: Path '{original_path}' is already registered on port {port}.")
|
||||
logging.warning(f"Using '{path}' instead of '{original_path}'")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Define the endpoint handler
|
||||
@_agents_shared_apps[port].post(path)
|
||||
@app.post(path)
|
||||
async def handle_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
@@ -1804,7 +2041,7 @@ class AgentTeam:
|
||||
agents_dict = {agent.display_name.lower().replace(' ', '_'): agent for agent in self.agents}
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@_agents_shared_apps[port].get(f"{path}/list")
|
||||
@app.get(f"{path}/list")
|
||||
async def list_agents():
|
||||
return {
|
||||
"agents": [
|
||||
@@ -1850,45 +2087,19 @@ class AgentTeam:
|
||||
)
|
||||
return handle_single_agent
|
||||
|
||||
# Register the endpoint with thread safety
|
||||
_agents_shared_apps[port].post(agent_path)(create_agent_handler(agent_instance))
|
||||
with _agents_server_lock:
|
||||
_agents_registered_endpoints[port][agent_path] = f"{endpoint_id}_{agent_id}"
|
||||
# Register the endpoint
|
||||
app.post(agent_path)(create_agent_handler(agent_instance))
|
||||
_server_registry.register_route(port, agent_path, f"{endpoint_id}_{agent_id}")
|
||||
|
||||
print(f"🔗 Per-agent endpoints: {', '.join([f'{path}/{aid}' for aid in agents_dict.keys()])}")
|
||||
|
||||
# Start the server if it's not already running for this port
|
||||
with _agents_server_lock:
|
||||
if not _agents_server_started.get(port, False):
|
||||
# Mark the server as started first to prevent duplicate starts
|
||||
_agents_server_started[port] = True
|
||||
should_start_server = True
|
||||
else:
|
||||
should_start_server = False
|
||||
|
||||
if should_start_server:
|
||||
if _server_registry.start_server_if_needed(port, host, log_level="debug" if debug else "info"):
|
||||
print(f"✅ FastAPI server started at http://{host}:{port}")
|
||||
print(f"📚 API documentation available at http://{host}:{port}/docs")
|
||||
|
||||
# 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"🔌 Registered HTTP endpoints on port {port}: {', '.join(list(_agents_registered_endpoints[port].keys()))}")
|
||||
uvicorn.run(_agents_shared_apps[port], host=host, port=port, log_level="debug" if debug else "info")
|
||||
except Exception as e:
|
||||
logging.error(f"Error starting server: {str(e)}", exc_info=True)
|
||||
print(f"❌ Error starting server: {str(e)}")
|
||||
|
||||
# Run server in a background thread
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Wait for a moment to allow the server to start and register endpoints
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
# If server is already running, wait a moment to make sure the endpoint is registered
|
||||
time.sleep(0.1)
|
||||
print(f"🔌 Registered HTTP endpoints on port {port}: {', '.join(list(_agents_registered_endpoints[port].keys()))}")
|
||||
endpoints = _server_registry.list_routes(port)
|
||||
print(f"🔌 Registered HTTP endpoints on port {port}: {', '.join(endpoints)}")
|
||||
|
||||
# Get the stack frame to check if this is the last launch() call in the script
|
||||
import inspect
|
||||
@@ -2468,6 +2679,86 @@ class AgentTeam:
|
||||
self._plan_tasks = self.tasks.copy()
|
||||
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
|
||||
|
||||
# Resource Lifecycle Management
|
||||
def close(self) -> None:
|
||||
"""Close all agent resources and cleanup connections.
|
||||
|
||||
This method ensures proper cleanup of:
|
||||
- Agent resources (connections, file handles)
|
||||
- Shared memory connections (SQLite, ChromaDB, MongoDB)
|
||||
- Context manager resources
|
||||
"""
|
||||
from .._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Close all agents
|
||||
for agent in self.agents:
|
||||
try:
|
||||
if hasattr(agent, 'close') and callable(agent.close):
|
||||
agent.close()
|
||||
logger.debug(f"Closed resources for agent: {agent.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Agent {getattr(agent, 'name', 'unknown')} cleanup failed: {e}")
|
||||
|
||||
# Close shared memory resources
|
||||
if hasattr(self, 'shared_memory') and self.shared_memory:
|
||||
try:
|
||||
if hasattr(self.shared_memory, 'close') and callable(self.shared_memory.close):
|
||||
self.shared_memory.close()
|
||||
logger.debug("Closed shared memory resources")
|
||||
except Exception as e:
|
||||
logger.warning(f"Shared memory cleanup failed: {e}")
|
||||
|
||||
# Close context manager if initialized
|
||||
if hasattr(self, '_context_manager') and self._context_manager:
|
||||
try:
|
||||
if hasattr(self._context_manager, 'close') and callable(self._context_manager.close):
|
||||
self._context_manager.close()
|
||||
logger.debug("Closed context manager resources")
|
||||
except Exception as e:
|
||||
logger.warning(f"Context manager cleanup failed: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry point for resource management."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit point - ensures resource cleanup."""
|
||||
self.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry point for resource management."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit point - ensures async resource cleanup."""
|
||||
from .._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Close all agents async if they support it
|
||||
for agent in self.agents:
|
||||
try:
|
||||
if hasattr(agent, 'aclose') and callable(agent.aclose):
|
||||
await agent.aclose()
|
||||
logger.debug(f"Async closed resources for agent: {agent.name}")
|
||||
elif hasattr(agent, 'close') and callable(agent.close):
|
||||
agent.close()
|
||||
logger.debug(f"Closed resources for agent: {agent.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Agent {getattr(agent, 'name', 'unknown')} async cleanup failed: {e}")
|
||||
|
||||
# Close shared memory resources (async if supported)
|
||||
if hasattr(self, 'shared_memory') and self.shared_memory:
|
||||
try:
|
||||
if hasattr(self.shared_memory, 'aclose') and callable(self.shared_memory.aclose):
|
||||
await self.shared_memory.aclose()
|
||||
logger.debug("Async closed shared memory resources")
|
||||
elif hasattr(self.shared_memory, 'close') and callable(self.shared_memory.close):
|
||||
self.shared_memory.close()
|
||||
logger.debug("Closed shared memory resources")
|
||||
except Exception as e:
|
||||
logger.warning(f"Shared memory async cleanup failed: {e}")
|
||||
|
||||
# Backward compatibility aliases (silent - no deprecation warnings)
|
||||
# AgentTeam is the primary name (v1.0+)
|
||||
# AgentManager, Agents, PraisonAIAgents are silent aliases
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import warnings
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from enum import Enum
|
||||
@@ -147,6 +148,7 @@ class SubagentDelegator:
|
||||
# Note: asyncio.Semaphore() is created lazily via _get_semaphore() to support
|
||||
# Python 3.9 where Semaphore() requires an event loop at creation time.
|
||||
self.__semaphore: Optional[asyncio.Semaphore] = None
|
||||
self.__semaphore_lock: threading.Lock = threading.Lock() # Thread-safe initialization
|
||||
|
||||
def _get_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Get the semaphore, creating it lazily if needed.
|
||||
@@ -155,7 +157,9 @@ class SubagentDelegator:
|
||||
where asyncio.Semaphore() calls get_event_loop() at creation time.
|
||||
"""
|
||||
if self.__semaphore is None:
|
||||
self.__semaphore = asyncio.Semaphore(self.config.max_concurrent_subagents)
|
||||
with self.__semaphore_lock: # Thread-safe initialization
|
||||
if self.__semaphore is None: # Double-check after acquiring lock
|
||||
self.__semaphore = asyncio.Semaphore(self.config.max_concurrent_subagents)
|
||||
return self.__semaphore
|
||||
|
||||
def get_available_agents(self) -> List[str]:
|
||||
|
||||
@@ -183,14 +183,21 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
raise RuntimeError("Use sync fallback in async context")
|
||||
except RuntimeError:
|
||||
decision = asyncio.run(request_approval(tool_name, kwargs))
|
||||
from ..utils.async_bridge import is_async_context, run_coroutine_from_any_context
|
||||
registry = get_approval_registry()
|
||||
if is_async_context():
|
||||
# We're in an async context - cannot safely use console I/O
|
||||
raise RuntimeError(
|
||||
f"Tool '{tool_name}' requires approval but cannot use console I/O from async context. "
|
||||
f"Configure a non-console approval backend or call from sync context."
|
||||
)
|
||||
else:
|
||||
# Safe to run async approval using registry
|
||||
decision = run_coroutine_from_any_context(request_approval(tool_name, kwargs))
|
||||
except Exception as e:
|
||||
logging.warning(f"Async approval failed, using sync fallback: {e}")
|
||||
decision = console_approval_callback(tool_name, kwargs, risk_level)
|
||||
logging.warning(f"Approval request failed: {e}", exc_info=True)
|
||||
# Fail closed - do not fall back to console
|
||||
raise PermissionError(f"Approval request failed for {tool_name}: {e}") from e
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
|
||||
@@ -47,16 +47,31 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
}
|
||||
|
||||
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
|
||||
# Usage: Agent(approval="safe")
|
||||
# Usage: Agent(approval="safe") — or set PRAISONAI_TOOL_SAFETY=<preset>
|
||||
# which applies as the default when no ``approval=`` kwarg is passed.
|
||||
#
|
||||
# ``default`` is the baseline we apply when nothing is configured: it
|
||||
# only blocks operations the LLM should never execute unattended —
|
||||
# destructive file ops (delete/move/copy) and arbitrary shell/code
|
||||
# execution. Read, create and edit stay allowed because those are
|
||||
# what 99% of useful agent workflows need. Users who want the old
|
||||
# ``trust the LLM with everything`` behaviour pass ``approval="full"``
|
||||
# or set ``PRAISONAI_TOOL_SAFETY=off``; users who want stricter
|
||||
# controls can opt into ``approval="safe"`` or ``"read_only"``.
|
||||
PERMISSION_PRESETS = {
|
||||
# "default" — blocks delete + shell/code exec. Allows read/create/edit.
|
||||
"default": frozenset({
|
||||
"execute_command", "kill_process", "execute_code", "acp_execute_command",
|
||||
"delete_file", "move_file", "copy_file", "acp_delete_file",
|
||||
}),
|
||||
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
|
||||
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "read_only" — blocks dangerous tools + write operations
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()) | frozenset({
|
||||
"write_file", "copy_file", "move_file",
|
||||
}),
|
||||
# "full" — no restrictions
|
||||
# "read_only" — alias of "safe" (blocks all dangerous tools)
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "full" — no restrictions (trust the LLM). Equivalent to "off" env.
|
||||
"full": frozenset(),
|
||||
# "off" — alias of "full" for the env-var off-switch.
|
||||
"off": frozenset(),
|
||||
}
|
||||
|
||||
class ApprovalRegistry:
|
||||
@@ -76,6 +91,9 @@ class ApprovalRegistry:
|
||||
self._required_tools: Set[str] = set()
|
||||
self._risk_levels: Dict[str, str] = {}
|
||||
|
||||
# Per-agent, per-tool auto-approval (G-A fix)
|
||||
self._agent_tool_auto_approve: Dict[tuple[str, str], bool] = {}
|
||||
|
||||
# Context variables (per-coroutine / per-thread)
|
||||
self._approved_context: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
|
||||
"approved_context", default=set()
|
||||
@@ -139,6 +157,20 @@ class ApprovalRegistry:
|
||||
def get_risk_level(self, tool_name: str) -> Optional[str]:
|
||||
return self._risk_levels.get(tool_name)
|
||||
|
||||
# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────
|
||||
|
||||
def auto_approve_tool(self, tool_name: str, agent_name: str) -> None:
|
||||
"""Pre-approve a single tool for a specific agent."""
|
||||
if not agent_name:
|
||||
raise ValueError("Skill auto-approval requires a stable agent/session scope")
|
||||
self._agent_tool_auto_approve[(agent_name, tool_name)] = True
|
||||
|
||||
def is_auto_approved(self, tool_name: str, agent_name: str) -> bool:
|
||||
"""Check if a tool is auto-approved for a specific agent."""
|
||||
if not agent_name:
|
||||
return False
|
||||
return self._agent_tool_auto_approve.get((agent_name, tool_name), False)
|
||||
|
||||
# ── Context helpers ──────────────────────────────────────────────────
|
||||
|
||||
def mark_approved(self, tool_name: str) -> None:
|
||||
@@ -189,6 +221,11 @@ class ApprovalRegistry:
|
||||
if self.is_already_approved(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
# Env auto-approve
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
@@ -237,6 +274,11 @@ class ApprovalRegistry:
|
||||
if self.is_already_approved(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Authentication & Authorization Protocols for PraisonAI Agents.
|
||||
|
||||
This package provides protocol-driven auth interfaces for the core SDK.
|
||||
Implementations (JWT, OAuth, etc.) live in the platform package.
|
||||
|
||||
Quick start::
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity, AuthBackendProtocol
|
||||
|
||||
class MyAuthBackend:
|
||||
async def authenticate(self, credentials):
|
||||
return AuthIdentity(id="user-1", type="user", roles=["owner"])
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
AuthIdentity,
|
||||
AuthBackendProtocol,
|
||||
WorkspaceContextProtocol,
|
||||
AuthConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Auth protocol definitions for PraisonAI Agents.
|
||||
|
||||
Defines the structural contracts for authentication and authorization
|
||||
backends via typing.Protocol. Any class implementing the required methods
|
||||
satisfies the protocol without explicit inheritance (structural subtyping).
|
||||
|
||||
Design influenced by:
|
||||
- Multica: JWT-based auth with email codes + Google OAuth, role-based
|
||||
workspace membership (owner/admin/member), agents as assignees.
|
||||
- Paperclip: better-auth sessions, company memberships with principal
|
||||
types (user/agent), fine-grained permission grants.
|
||||
- PraisonAI patterns: ApprovalProtocol, GatewayProtocol, DbAdapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthIdentity:
|
||||
"""Authenticated identity (user or agent).
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this principal.
|
||||
type: Principal type — ``"user"`` or ``"agent"``.
|
||||
workspace_id: Active workspace/tenant scope (optional).
|
||||
roles: Roles within the workspace (e.g. ``["owner"]``).
|
||||
email: Email address (users only, optional).
|
||||
name: Display name (optional).
|
||||
metadata: Arbitrary extra data for backend-specific info.
|
||||
"""
|
||||
|
||||
id: str
|
||||
type: str = "user"
|
||||
workspace_id: Optional[str] = None
|
||||
roles: List[str] = field(default_factory=list)
|
||||
email: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_user(self) -> bool:
|
||||
"""Check if this identity is a human user."""
|
||||
return self.type == "user"
|
||||
|
||||
@property
|
||||
def is_agent(self) -> bool:
|
||||
"""Check if this identity is an agent."""
|
||||
return self.type == "agent"
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""Check if this identity has a specific role."""
|
||||
return role in self.roles
|
||||
|
||||
def has_any_role(self, *roles: str) -> bool:
|
||||
"""Check if this identity has any of the specified roles."""
|
||||
return bool(set(roles) & set(self.roles))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Configuration for authentication behaviour.
|
||||
|
||||
Follows PraisonAI's ``False/True/Config`` progressive-disclosure pattern:
|
||||
|
||||
- ``auth=False`` — disabled (no auth checks).
|
||||
- ``auth=True`` — use default backend from registry.
|
||||
- ``auth=AuthConfig(...)`` — full control.
|
||||
|
||||
Attributes:
|
||||
backend: An :class:`AuthBackendProtocol` backend.
|
||||
``None`` falls back to registry default.
|
||||
token_ttl: Token time-to-live in seconds (default: 30 days).
|
||||
require_workspace: Whether workspace_id is required for authorization.
|
||||
"""
|
||||
|
||||
backend: Any = None
|
||||
token_ttl: int = 30 * 24 * 3600
|
||||
require_workspace: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthBackendProtocol(Protocol):
|
||||
"""Protocol for authentication/authorization backends.
|
||||
|
||||
Implement ``authenticate`` and ``authorize`` to create custom auth:
|
||||
- JWT token validation
|
||||
- API key lookup
|
||||
- OAuth token exchange
|
||||
- Session cookie resolution
|
||||
|
||||
Example::
|
||||
|
||||
class JWTAuthBackend:
|
||||
async def authenticate(
|
||||
self, credentials: dict
|
||||
) -> AuthIdentity | None:
|
||||
token = credentials.get("token")
|
||||
payload = decode_jwt(token)
|
||||
return AuthIdentity(
|
||||
id=payload["sub"],
|
||||
type="user",
|
||||
email=payload.get("email"),
|
||||
roles=payload.get("roles", []),
|
||||
)
|
||||
|
||||
async def authorize(
|
||||
self, identity: AuthIdentity, resource: str, action: str
|
||||
) -> bool:
|
||||
return identity.has_role("admin")
|
||||
"""
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
credentials: Dict[str, Any],
|
||||
) -> Optional[AuthIdentity]:
|
||||
"""Authenticate credentials and return an identity.
|
||||
|
||||
Args:
|
||||
credentials: Backend-specific credentials dict.
|
||||
Common keys: ``token``, ``api_key``, ``email``, ``password``,
|
||||
``session_id``.
|
||||
|
||||
Returns:
|
||||
AuthIdentity if authentication succeeds, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def authorize(
|
||||
self,
|
||||
identity: AuthIdentity,
|
||||
resource: str,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Check if an identity is authorized for an action on a resource.
|
||||
|
||||
Args:
|
||||
identity: The authenticated identity.
|
||||
resource: Resource identifier (e.g. ``"workspace:ws-123"``
|
||||
or ``"issue:ISS-42"``).
|
||||
action: Action to perform (e.g. ``"read"``, ``"write"``,
|
||||
``"delete"``, ``"admin"``).
|
||||
|
||||
Returns:
|
||||
True if authorized, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WorkspaceContextProtocol(Protocol):
|
||||
"""Protocol for providing workspace context to agents.
|
||||
|
||||
Enables agents to access workspace-specific settings, instructions,
|
||||
and agent configurations from the platform layer.
|
||||
|
||||
Example::
|
||||
|
||||
class PlatformWorkspaceContext:
|
||||
async def get_workspace_context(self, workspace_id):
|
||||
ws = await db.get_workspace(workspace_id)
|
||||
return ws.context
|
||||
|
||||
async def get_agent_config(self, workspace_id, agent_id):
|
||||
agent = await db.get_agent(workspace_id, agent_id)
|
||||
return {"system_prompt": agent.system_prompt, ...}
|
||||
"""
|
||||
|
||||
async def get_workspace_context(
|
||||
self,
|
||||
workspace_id: str,
|
||||
) -> Optional[str]:
|
||||
"""Get workspace-level context/instructions for agents.
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace identifier.
|
||||
|
||||
Returns:
|
||||
Context string if available, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_agent_config(
|
||||
self,
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get agent configuration from the platform.
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace identifier.
|
||||
agent_id: The agent identifier.
|
||||
|
||||
Returns:
|
||||
Agent configuration dict if found, None otherwise.
|
||||
Expected keys: ``system_prompt``, ``model``, ``tools``,
|
||||
``max_concurrent_tasks``.
|
||||
"""
|
||||
...
|
||||
@@ -6,6 +6,7 @@ Manages background task execution.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any, Callable, Union
|
||||
from datetime import datetime
|
||||
@@ -53,13 +54,16 @@ class BackgroundRunner:
|
||||
self._tasks: Dict[str, BackgroundTask] = {}
|
||||
# Lazily create asyncio.Semaphore() to avoid Python 3.9 event loop issues
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
self._semaphore_lock: threading.Lock = threading.Lock() # Thread-safe initialization
|
||||
self._running = False
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
def _get_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Get or create the semaphore lazily (Python 3.9 compatible)."""
|
||||
if self._semaphore is None:
|
||||
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
|
||||
with self._semaphore_lock: # Thread-safe initialization
|
||||
if self._semaphore is None: # Double-check after acquiring lock
|
||||
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
|
||||
return self._semaphore
|
||||
|
||||
@property
|
||||
@@ -403,7 +407,7 @@ def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return a running event loop on a background daemon thread.
|
||||
|
||||
Created once on first call; reused thereafter. The thread is a
|
||||
daemon so it won't prevent process exit.
|
||||
daemon so it won't prevent process exit, but includes graceful shutdown.
|
||||
"""
|
||||
global _bg_loop
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
@@ -420,3 +424,31 @@ def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
t.start()
|
||||
|
||||
return _bg_loop
|
||||
|
||||
|
||||
def _shutdown_bg_loop():
|
||||
"""Gracefully shutdown the background event loop."""
|
||||
global _bg_loop
|
||||
if _bg_loop is None or not _bg_loop.is_running():
|
||||
return
|
||||
|
||||
async def _cancel_pending():
|
||||
current = asyncio.current_task()
|
||||
pending = [t for t in asyncio.all_tasks() if t is not current]
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(_cancel_pending(), _bg_loop)
|
||||
fut.result(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_bg_loop.call_soon_threadsafe(_bg_loop.stop)
|
||||
|
||||
|
||||
# Register cleanup on process exit
|
||||
import atexit
|
||||
atexit.register(_shutdown_bg_loop)
|
||||
@@ -5,7 +5,9 @@ Provides configuration dataclasses for bot settings.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from .pairing_types import UnknownUserPolicy
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,16 +50,32 @@ class BotConfig:
|
||||
# Group message policy
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
# Default safe tools (auto-injected for bots with no tools configured)
|
||||
# With workspace scoping, file operations are now safe by construction
|
||||
default_tools: List[str] = field(default_factory=lambda: [
|
||||
"execute_command", "search_web", "web_crawl",
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
# Web (existing)
|
||||
"search_web", "web_crawl",
|
||||
# Memory / learning (existing)
|
||||
"store_memory", "search_memory",
|
||||
"store_learning", "search_learning",
|
||||
# Scheduling (existing)
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
# Clarify tool (new from main)
|
||||
"clarify",
|
||||
# Files — NEW (workspace-scoped, safe by construction)
|
||||
"read_file", "write_file", "edit_file", "list_files", "search_files",
|
||||
# Planning — NEW
|
||||
"todo_add", "todo_list", "todo_update",
|
||||
# Skills (self-improving) — NEW
|
||||
"skills_list", "skill_view", "skill_manage",
|
||||
# Delegation & session tools are intentionally NOT auto-injected yet;
|
||||
# their reference implementations are placeholders. Users can opt in
|
||||
# via BotConfig(default_tools=[..., "delegate_task", "session_search"]).
|
||||
])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
# Default to True for chat bots - they can't show CLI approval prompts
|
||||
auto_approve_tools: bool = True # If True, skip confirmation for safe tool execution
|
||||
|
||||
# Inbound message debounce (ms). Coalesces rapid messages from same user.
|
||||
# 0 = disabled (default). Recommended: 1000-2000 for chat bots.
|
||||
@@ -73,6 +91,24 @@ class BotConfig:
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
session_ttl: int = 0
|
||||
|
||||
# Workspace settings for file operation containment and security
|
||||
workspace_dir: Optional[str] = None # default: ~/.praisonai/workspaces/<scope>/<session_key>
|
||||
workspace_access: str = "rw" # "rw" (read-write) | "ro" (read-only) | "none" (copy-on-write sandbox)
|
||||
workspace_scope: str = "session" # "shared" | "session" | "user" | "agent"
|
||||
|
||||
# Unknown user policy: "deny" (default), "pair", or "allow"
|
||||
unknown_user_policy: UnknownUserPolicy = "deny"
|
||||
|
||||
# Owner user ID for pairing approvals (platform-specific format)
|
||||
owner_user_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
|
||||
raise ValueError(
|
||||
f"unknown_user_policy must be one of: deny, pair, allow. Got: {self.unknown_user_policy}"
|
||||
)
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -100,6 +136,11 @@ class BotConfig:
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"session_ttl": self.session_ttl,
|
||||
"workspace_dir": self.workspace_dir,
|
||||
"workspace_access": self.workspace_access,
|
||||
"workspace_scope": self.workspace_scope,
|
||||
"unknown_user_policy": self.unknown_user_policy,
|
||||
"owner_user_id": "***" if self.owner_user_id else None,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Pairing types and protocols for bot user approval.
|
||||
|
||||
Contains lightweight types and protocols for the pairing approval system.
|
||||
Heavy implementations are in the wrapper package.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
|
||||
# Policy for handling unknown users
|
||||
UnknownUserPolicy = Literal["deny", "pair", "allow"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingReply:
|
||||
"""Reply data for pairing approval requests."""
|
||||
|
||||
user_name: str
|
||||
user_id: str
|
||||
channel: str
|
||||
code: str
|
||||
message: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.message:
|
||||
self.message = f"User {self.user_name} wants to chat. Approve access?"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingApprovalResult:
|
||||
"""Result of a pairing approval action."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
user_id: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
from dataclasses import dataclass, field
|
||||
from .event import Event, EventType
|
||||
|
||||
@@ -209,13 +210,13 @@ class EventBus:
|
||||
for sub in subscribers:
|
||||
try:
|
||||
if sub.is_async:
|
||||
# Schedule async callback
|
||||
# Schedule async callback safely
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(sub.callback(event))
|
||||
except RuntimeError:
|
||||
# No running loop, run synchronously
|
||||
asyncio.run(sub.callback(event))
|
||||
# No running loop, use safe async bridge
|
||||
run_coroutine_from_any_context(sub.callback(event))
|
||||
else:
|
||||
sub.callback(event)
|
||||
except Exception as e:
|
||||
|
||||
@@ -485,9 +485,17 @@ class CheckpointService:
|
||||
if len(self._checkpoints) <= self.config.max_checkpoints:
|
||||
return
|
||||
|
||||
# Keep only the most recent checkpoints
|
||||
# Note: This doesn't actually delete git history, just our tracking
|
||||
self._checkpoints = self._checkpoints[:self.config.max_checkpoints]
|
||||
# Calculate how many to remove
|
||||
num_to_remove = len(self._checkpoints) - self.config.max_checkpoints
|
||||
|
||||
# Keep only the most recent checkpoints in memory (newest-last semantics)
|
||||
# Since save() appends (newest last), keep the last N entries
|
||||
self._checkpoints = self._checkpoints[-self.config.max_checkpoints:]
|
||||
|
||||
logger.info(f"Pruned {num_to_remove} old checkpoints to stay under limit of {self.config.max_checkpoints}")
|
||||
|
||||
# Emit pruning event for any cleanup hooks
|
||||
self._emit(CheckpointEvent.CHECKPOINTS_PRUNED, {"action": "pruned", "removed_count": num_to_remove})
|
||||
|
||||
async def get_checkpoint(self, checkpoint_id: str) -> Optional[Checkpoint]:
|
||||
"""Get a specific checkpoint by ID."""
|
||||
|
||||
@@ -26,6 +26,7 @@ class CheckpointEvent(str, Enum):
|
||||
INITIALIZED = "initialized"
|
||||
CHECKPOINT_CREATED = "checkpoint_created"
|
||||
CHECKPOINT_RESTORED = "checkpoint_restored"
|
||||
CHECKPOINTS_PRUNED = "checkpoints_pruned"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""CLI Backend Protocol for PraisonAI Agents.
|
||||
|
||||
Provides protocol-driven core for integrating external CLI tools as agent backends.
|
||||
|
||||
This module follows AGENTS.md protocol-driven design:
|
||||
- Core SDK contains only protocols and dataclasses
|
||||
- Heavy implementations live in wrapper (praisonai package)
|
||||
- Zero optional dependencies at import time
|
||||
"""
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for CLI backend components."""
|
||||
if name == "CliBackendProtocol":
|
||||
from .protocols import CliBackendProtocol
|
||||
return CliBackendProtocol
|
||||
elif name == "CliBackendConfig":
|
||||
from .protocols import CliBackendConfig
|
||||
return CliBackendConfig
|
||||
elif name == "CliSessionBinding":
|
||||
from .protocols import CliSessionBinding
|
||||
return CliSessionBinding
|
||||
elif name == "CliBackendResult":
|
||||
from .protocols import CliBackendResult
|
||||
return CliBackendResult
|
||||
elif name == "CliBackendDelta":
|
||||
from .protocols import CliBackendDelta
|
||||
return CliBackendDelta
|
||||
else:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
__all__ = [
|
||||
"CliBackendProtocol",
|
||||
"CliBackendConfig",
|
||||
"CliSessionBinding",
|
||||
"CliBackendResult",
|
||||
"CliBackendDelta"
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""CLI Backend protocols and data structures.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Lightweight protocols only (no implementations)
|
||||
- Dataclasses for configuration
|
||||
- Async-first with proper typing
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, AsyncIterator, Optional, List, Dict, Any, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendConfig:
|
||||
"""Declarative CLI backend configuration.
|
||||
|
||||
Mirrors OpenClaw's CliBackendConfig for cross-ecosystem compatibility.
|
||||
"""
|
||||
# Core command
|
||||
command: str # e.g., "claude", "codex", "gemini"
|
||||
args: List[str] = field(default_factory=list)
|
||||
|
||||
# Session management
|
||||
resume_args: Optional[List[str]] = None # supports "{session_id}" placeholder
|
||||
session_arg: Optional[str] = None # e.g., "--session-id"
|
||||
session_mode: str = "none" # "always" | "existing" | "none"
|
||||
session_id_fields: List[str] = field(default_factory=list)
|
||||
|
||||
# Input/output format
|
||||
output: str = "text" # "text" | "json" | "jsonl"
|
||||
input: str = "arg" # "arg" | "stdin"
|
||||
max_prompt_arg_chars: Optional[int] = None
|
||||
|
||||
# Model configuration
|
||||
model_arg: Optional[str] = None # e.g., "--model"
|
||||
model_aliases: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# System prompt handling
|
||||
system_prompt_arg: Optional[str] = None # e.g., "--append-system-prompt"
|
||||
system_prompt_when: str = "always" # "first" | "always" | "never"
|
||||
system_prompt_mode: str = "append" # "append" | "replace"
|
||||
|
||||
# Image support
|
||||
image_arg: Optional[str] = None # e.g., "--image"
|
||||
image_mode: str = "repeat" # "repeat" | "list"
|
||||
|
||||
# Environment
|
||||
clear_env: List[str] = field(default_factory=list) # Env vars to clear
|
||||
env: Dict[str, str] = field(default_factory=dict) # Env vars to set
|
||||
|
||||
# Advanced features
|
||||
live_session: Optional[str] = None # e.g., "claude-stdio"
|
||||
bundle_mcp: bool = False
|
||||
bundle_mcp_mode: Optional[str] = None # e.g., "claude-config-file"
|
||||
serialize: bool = False # Queue operations to avoid conflicts
|
||||
|
||||
# Timeouts
|
||||
no_output_timeout_ms: Optional[int] = None
|
||||
timeout_ms: int = 300_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliSessionBinding:
|
||||
"""Session binding for CLI backend state tracking."""
|
||||
session_id: Optional[str] = None
|
||||
auth_profile_id: Optional[str] = None
|
||||
system_prompt_hash: Optional[str] = None
|
||||
mcp_config_hash: Optional[str] = None
|
||||
is_resume: bool = False # Whether this is resuming an existing session
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendResult:
|
||||
"""Result from CLI backend execution."""
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
session_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendDelta:
|
||||
"""Streaming delta from CLI backend."""
|
||||
type: str # "text" | "tool_call" | "thinking" | "error"
|
||||
content: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CliBackendProtocol(Protocol):
|
||||
"""Protocol for CLI backend implementations.
|
||||
|
||||
Any object implementing these methods can serve as an Agent backend.
|
||||
Follows typing.Protocol pattern from AGENTS.md.
|
||||
"""
|
||||
config: CliBackendConfig
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
session: Optional[CliSessionBinding] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> CliBackendResult:
|
||||
"""Execute a single prompt and return result.
|
||||
|
||||
Args:
|
||||
prompt: User prompt/query
|
||||
session: Session binding for state management
|
||||
images: List of image paths for multimodal prompts
|
||||
system_prompt: System prompt override
|
||||
**kwargs: Additional backend-specific options
|
||||
|
||||
Returns:
|
||||
CliBackendResult with response content and metadata
|
||||
"""
|
||||
...
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs
|
||||
) -> AsyncIterator[CliBackendDelta]:
|
||||
"""Stream response deltas from CLI backend.
|
||||
|
||||
Args:
|
||||
prompt: User prompt/query
|
||||
**kwargs: Additional options (session, images, etc.)
|
||||
|
||||
Yields:
|
||||
CliBackendDelta objects with incremental response content
|
||||
"""
|
||||
...
|
||||
@@ -120,6 +120,11 @@ class LearnConfig:
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Nudge mechanism (self-improving agent loop)
|
||||
nudge_interval: int = 0 # 0=disabled; N=nudge every N turns
|
||||
nudge_min_tool_iters: int = 3 # Only nudge if agent did real work
|
||||
propose_skills: bool = False # Enable skill_manage tool (propose mode)
|
||||
|
||||
# Learning mode
|
||||
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
|
||||
|
||||
@@ -144,6 +149,9 @@ class LearnConfig:
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"nudge_interval": self.nudge_interval,
|
||||
"nudge_min_tool_iters": self.nudge_min_tool_iters,
|
||||
"propose_skills": self.propose_skills,
|
||||
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
@@ -735,6 +743,11 @@ class ExecutionConfig:
|
||||
# Action when budget exceeded: "stop" (default) raises BudgetExceededError,
|
||||
# "warn" logs warning but continues, or callable(total_cost, max_budget).
|
||||
on_budget_exceeded: Any = "stop"
|
||||
|
||||
# Parallel tool execution (Gap 2): Enable parallel execution of batched LLM tool calls
|
||||
# When True, multiple tool calls from LLM are executed concurrently instead of sequentially
|
||||
# Default False preserves existing behavior for backward compatibility
|
||||
parallel_tool_calls: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -749,6 +762,7 @@ class ExecutionConfig:
|
||||
"context_compaction": self.context_compaction,
|
||||
"max_context_tokens": self.max_context_tokens,
|
||||
"max_budget": self.max_budget,
|
||||
"parallel_tool_calls": self.parallel_tool_calls,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -292,6 +292,44 @@ class HandoffError(PraisonAIError):
|
||||
self.target_agent = target_agent
|
||||
|
||||
|
||||
class PraisonAIConfigError(PraisonAIError):
|
||||
"""
|
||||
Configuration error (missing API keys, invalid settings, etc).
|
||||
|
||||
Usually indicates setup issues that require user action.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
config_key: Optional[str] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = False, # Config errors need user intervention
|
||||
remediation_hint: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
if config_key:
|
||||
context.update({"config_key": config_key})
|
||||
if remediation_hint is None:
|
||||
remediation_hint = f"Set {config_key} or run the setup wizard before retrying."
|
||||
if remediation_hint:
|
||||
context["remediation_hint"] = remediation_hint
|
||||
message = f"{message} Remediation: {remediation_hint}"
|
||||
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="validation", # Configuration is a type of validation error
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.config_key = config_key
|
||||
self.remediation_hint = remediation_hint
|
||||
|
||||
|
||||
# Specialized handoff errors (maintain backward compatibility)
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Circular handoff dependency detected."""
|
||||
@@ -341,5 +379,6 @@ __all__ = [
|
||||
"HandoffError",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffTimeoutError"
|
||||
"HandoffTimeoutError",
|
||||
"PraisonAIConfigError"
|
||||
]
|
||||
@@ -18,8 +18,25 @@ from .protocols import (
|
||||
GatewayEvent,
|
||||
GatewayMessage,
|
||||
EventType,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Push config
|
||||
PushConfig,
|
||||
RedisConfig,
|
||||
PresenceConfig,
|
||||
DeliveryConfig,
|
||||
PollingConfig,
|
||||
)
|
||||
from .config import GatewayConfig, SessionConfig, ChannelRouteConfig, MultiChannelGatewayConfig
|
||||
|
||||
# Lazy loading cache
|
||||
_lazy_cache = {}
|
||||
@@ -68,11 +85,22 @@ __all__ = [
|
||||
"GatewayEvent",
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
"PushConfig",
|
||||
"RedisConfig",
|
||||
"PresenceConfig",
|
||||
"DeliveryConfig",
|
||||
"PollingConfig",
|
||||
# Implementations (lazy loaded from praisonai wrapper)
|
||||
"WebSocketGateway",
|
||||
"GatewaySession",
|
||||
|
||||
@@ -37,6 +37,158 @@ class SessionConfig:
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push notification configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class RedisConfig:
|
||||
"""Redis connection configuration for push service scaling.
|
||||
|
||||
Attributes:
|
||||
url: Full Redis URL (takes precedence over host/port)
|
||||
host: Redis host
|
||||
port: Redis port
|
||||
db: Redis database number
|
||||
password: Redis password
|
||||
prefix: Key prefix namespace
|
||||
max_connections: Connection pool size
|
||||
"""
|
||||
|
||||
url: Optional[str] = None
|
||||
host: str = "localhost"
|
||||
port: int = 6379
|
||||
db: int = 0
|
||||
password: Optional[str] = None
|
||||
prefix: str = "praison:push:"
|
||||
max_connections: int = 20
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
"url": "***" if self.url else None,
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"db": self.db,
|
||||
"password": "***" if self.password else None,
|
||||
"prefix": self.prefix,
|
||||
"max_connections": self.max_connections,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresenceConfig:
|
||||
"""Configuration for presence tracking.
|
||||
|
||||
Attributes:
|
||||
enabled: Toggle presence tracking
|
||||
heartbeat_interval: Expected heartbeat frequency from clients (seconds)
|
||||
offline_timeout: Mark offline after this many seconds without heartbeat
|
||||
broadcast_changes: Broadcast presence changes to subscribed channels
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
heartbeat_interval: int = 15
|
||||
offline_timeout: int = 45
|
||||
broadcast_changes: bool = True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"heartbeat_interval": self.heartbeat_interval,
|
||||
"offline_timeout": self.offline_timeout,
|
||||
"broadcast_changes": self.broadcast_changes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryConfig:
|
||||
"""Configuration for at-least-once delivery guarantees.
|
||||
|
||||
Attributes:
|
||||
enabled: Toggle delivery guarantees
|
||||
ack_timeout: Seconds to wait for ACK before retrying
|
||||
max_retries: Maximum retry attempts
|
||||
retry_backoff: Exponential backoff multiplier
|
||||
message_ttl: How long to retain unacknowledged messages (seconds)
|
||||
store_backend: Message store backend ("memory" or "redis")
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
ack_timeout: int = 30
|
||||
max_retries: int = 3
|
||||
retry_backoff: float = 2.0
|
||||
message_ttl: int = 86400
|
||||
store_backend: str = "memory"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"ack_timeout": self.ack_timeout,
|
||||
"max_retries": self.max_retries,
|
||||
"retry_backoff": self.retry_backoff,
|
||||
"message_ttl": self.message_ttl,
|
||||
"store_backend": self.store_backend,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollingConfig:
|
||||
"""Configuration for HTTP long-polling fallback.
|
||||
|
||||
Attributes:
|
||||
enabled: Toggle polling fallback
|
||||
long_poll_timeout: Long-poll hang duration (seconds)
|
||||
max_batch_size: Max messages per poll response
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
long_poll_timeout: int = 30
|
||||
max_batch_size: int = 100
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"long_poll_timeout": self.long_poll_timeout,
|
||||
"max_batch_size": self.max_batch_size,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushConfig:
|
||||
"""Top-level configuration for the push notification service.
|
||||
|
||||
All push capabilities are opt-in. When enabled=False (default),
|
||||
zero overhead is added to the gateway.
|
||||
|
||||
Attributes:
|
||||
enabled: Feature toggle (push is opt-in)
|
||||
redis: Redis config for cross-server scaling (None = local-only)
|
||||
presence: Presence tracking settings
|
||||
delivery: Delivery guarantee settings
|
||||
polling: Polling fallback settings
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
redis: Optional[RedisConfig] = None
|
||||
presence: PresenceConfig = field(default_factory=PresenceConfig)
|
||||
delivery: DeliveryConfig = field(default_factory=DeliveryConfig)
|
||||
polling: PollingConfig = field(default_factory=PollingConfig)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"redis": self.redis.to_dict() if self.redis else None,
|
||||
"presence": self.presence.to_dict(),
|
||||
"delivery": self.delivery.to_dict(),
|
||||
"polling": self.polling.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Configuration for the gateway server.
|
||||
@@ -45,6 +197,7 @@ class GatewayConfig:
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
cors_origins: Allowed CORS origins
|
||||
allowed_origins: Allowed origins for WebSocket connections (CSWSH defense)
|
||||
auth_token: Optional authentication token
|
||||
max_connections: Maximum concurrent connections
|
||||
max_sessions_per_agent: Maximum sessions per agent (0 = unlimited)
|
||||
@@ -53,11 +206,14 @@ class GatewayConfig:
|
||||
reconnect_timeout: Time to wait for reconnection before closing session
|
||||
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
|
||||
ssl_key: Path to SSL key
|
||||
push: Push notification service configuration
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
bind_host: Optional[str] = None
|
||||
cors_origins: List[str] = field(default_factory=lambda: [])
|
||||
allowed_origins: List[str] = field(default_factory=lambda: [])
|
||||
auth_token: Optional[str] = None
|
||||
max_connections: int = 1000
|
||||
max_sessions_per_agent: int = 0 # 0 = unlimited
|
||||
@@ -66,6 +222,12 @@ class GatewayConfig:
|
||||
reconnect_timeout: int = 60
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified."""
|
||||
if self.bind_host is None:
|
||||
self.bind_host = self.host
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
@@ -73,6 +235,7 @@ class GatewayConfig:
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"cors_origins": self.cors_origins,
|
||||
"allowed_origins": self.allowed_origins,
|
||||
"auth_token": "***" if self.auth_token else None,
|
||||
"max_connections": self.max_connections,
|
||||
"max_sessions_per_agent": self.max_sessions_per_agent,
|
||||
@@ -80,6 +243,7 @@ class GatewayConfig:
|
||||
"heartbeat_interval": self.heartbeat_interval,
|
||||
"reconnect_timeout": self.reconnect_timeout,
|
||||
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
|
||||
"push": self.push.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -205,6 +369,7 @@ class MultiChannelGatewayConfig:
|
||||
host=gw_data.get("host", "127.0.0.1"),
|
||||
port=gw_data.get("port", 8765),
|
||||
cors_origins=gw_data.get("cors_origins", []),
|
||||
allowed_origins=gw_data.get("allowed_origins", []),
|
||||
auth_token=gw_data.get("auth_token"),
|
||||
max_connections=gw_data.get("max_connections", 1000),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Union,
|
||||
@@ -27,6 +28,7 @@ from typing import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
|
||||
|
||||
@@ -62,6 +64,26 @@ class EventType(str, Enum):
|
||||
HEALTH = "health"
|
||||
ERROR = "error"
|
||||
BROADCAST = "broadcast"
|
||||
|
||||
# Push channel events
|
||||
CHANNEL_SUBSCRIBE = "channel_subscribe"
|
||||
CHANNEL_UNSUBSCRIBE = "channel_unsubscribe"
|
||||
CHANNEL_MESSAGE = "channel_message"
|
||||
CHANNEL_CREATED = "channel_created"
|
||||
CHANNEL_DELETED = "channel_deleted"
|
||||
|
||||
# Presence events
|
||||
PRESENCE_JOIN = "presence_join"
|
||||
PRESENCE_LEAVE = "presence_leave"
|
||||
PRESENCE_UPDATE = "presence_update"
|
||||
|
||||
# Delivery events
|
||||
MESSAGE_NACK = "message_nack"
|
||||
DELIVERY_RETRY = "delivery_retry"
|
||||
|
||||
# Polling events
|
||||
POLL_REQUEST = "poll_request"
|
||||
POLL_RESPONSE = "poll_response"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -407,3 +429,478 @@ class GatewayProtocol(Protocol):
|
||||
- clients: Number of connected clients
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push notification dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ChannelInfo:
|
||||
"""Metadata about a push channel/topic.
|
||||
|
||||
Attributes:
|
||||
name: Channel name (unique identifier)
|
||||
created_at: Channel creation timestamp
|
||||
metadata: Arbitrary channel metadata
|
||||
subscriber_count: Current number of subscribers
|
||||
"""
|
||||
|
||||
name: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
subscriber_count: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"created_at": self.created_at,
|
||||
"metadata": self.metadata,
|
||||
"subscriber_count": self.subscriber_count,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ChannelInfo":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
name=data.get("name", ""),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
subscriber_count=data.get("subscriber_count", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresenceInfo:
|
||||
"""Presence information for a connected client.
|
||||
|
||||
Attributes:
|
||||
client_id: Client identifier
|
||||
status: Presence status ("online", "idle", "offline")
|
||||
last_seen: Last heartbeat timestamp
|
||||
metadata: Client-provided metadata (e.g., display name)
|
||||
channels: Channels this client is subscribed to
|
||||
"""
|
||||
|
||||
client_id: str
|
||||
status: str = "online"
|
||||
last_seen: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
channels: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"client_id": self.client_id,
|
||||
"status": self.status,
|
||||
"last_seen": self.last_seen,
|
||||
"metadata": self.metadata,
|
||||
"channels": self.channels,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PresenceInfo":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
client_id=data.get("client_id", ""),
|
||||
status=data.get("status", "online"),
|
||||
last_seen=data.get("last_seen", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
channels=data.get("channels", []),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push notification protocols
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class PushChannelProtocol(Protocol):
|
||||
"""Protocol for channel/topic-based push messaging.
|
||||
|
||||
Channels group clients by interest. Messages published to a channel
|
||||
are delivered to all subscribed clients.
|
||||
"""
|
||||
|
||||
def add_channel(
|
||||
self, channel_name: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Create a named channel.
|
||||
|
||||
Args:
|
||||
channel_name: Unique channel name
|
||||
metadata: Optional channel metadata
|
||||
|
||||
Returns:
|
||||
True if created, False if already exists
|
||||
"""
|
||||
...
|
||||
|
||||
def remove_channel(self, channel_name: str) -> bool:
|
||||
"""Delete a channel and unsubscribe all clients.
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def get_channel(self, channel_name: str) -> Optional[ChannelInfo]:
|
||||
"""Get channel metadata."""
|
||||
...
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""List all active channel names."""
|
||||
...
|
||||
|
||||
def subscribe_client(self, client_id: str, channel_name: str) -> bool:
|
||||
"""Subscribe a client to a channel.
|
||||
|
||||
Returns:
|
||||
True if subscribed, False if already subscribed or channel missing
|
||||
"""
|
||||
...
|
||||
|
||||
def unsubscribe_client(self, client_id: str, channel_name: str) -> bool:
|
||||
"""Unsubscribe a client from a channel.
|
||||
|
||||
Returns:
|
||||
True if unsubscribed, False if not subscribed
|
||||
"""
|
||||
...
|
||||
|
||||
def get_subscribers(self, channel_name: str) -> List[str]:
|
||||
"""List client IDs subscribed to a channel."""
|
||||
...
|
||||
|
||||
def get_client_channels(self, client_id: str) -> List[str]:
|
||||
"""List channels a client is subscribed to."""
|
||||
...
|
||||
|
||||
async def publish_to_channel(
|
||||
self,
|
||||
channel_name: str,
|
||||
event: GatewayEvent,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> int:
|
||||
"""Publish an event to all subscribers of a channel.
|
||||
|
||||
Args:
|
||||
channel_name: Target channel
|
||||
event: The event to deliver
|
||||
exclude: Optional client IDs to skip
|
||||
|
||||
Returns:
|
||||
Number of clients the event was sent to
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PresenceProtocol(Protocol):
|
||||
"""Protocol for tracking client presence (online/idle/offline)."""
|
||||
|
||||
async def track_presence(
|
||||
self,
|
||||
client_id: str,
|
||||
status: str = "online",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Set or update a client's presence status.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
status: Presence status ("online", "idle", "offline")
|
||||
metadata: Optional client metadata
|
||||
"""
|
||||
...
|
||||
|
||||
async def remove_presence(self, client_id: str) -> None:
|
||||
"""Remove a client's presence (on disconnect)."""
|
||||
...
|
||||
|
||||
def get_presence(self, client_id: str) -> Optional[PresenceInfo]:
|
||||
"""Get a single client's presence info."""
|
||||
...
|
||||
|
||||
def get_all_presence(
|
||||
self, channel_name: Optional[str] = None,
|
||||
) -> List[PresenceInfo]:
|
||||
"""Get presence info, optionally filtered by channel.
|
||||
|
||||
Args:
|
||||
channel_name: If provided, only return presence for channel members
|
||||
"""
|
||||
...
|
||||
|
||||
def get_online_count(self, channel_name: Optional[str] = None) -> int:
|
||||
"""Count online clients, optionally filtered by channel."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeliveryGuaranteeProtocol(Protocol):
|
||||
"""Protocol for at-least-once message delivery.
|
||||
|
||||
Messages are stored, tracked, and retried until acknowledged.
|
||||
"""
|
||||
|
||||
async def store_message(self, event: GatewayEvent) -> str:
|
||||
"""Persist a message to the store.
|
||||
|
||||
Returns:
|
||||
The event_id of the stored message
|
||||
"""
|
||||
...
|
||||
|
||||
async def acknowledge(self, client_id: str, event_id: str) -> bool:
|
||||
"""Mark a message as acknowledged by a client.
|
||||
|
||||
Returns:
|
||||
True if found and acknowledged, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
async def nack(self, client_id: str, event_id: str) -> None:
|
||||
"""Negative acknowledge - request redelivery."""
|
||||
...
|
||||
|
||||
async def get_unacknowledged(
|
||||
self, client_id: str, limit: int = 100,
|
||||
) -> List[GatewayEvent]:
|
||||
"""Get pending unacknowledged messages for a client."""
|
||||
...
|
||||
|
||||
async def retry_unacknowledged(self, client_id: str) -> int:
|
||||
"""Redeliver all unacknowledged messages to a client.
|
||||
|
||||
Returns:
|
||||
Number of messages redelivered
|
||||
"""
|
||||
...
|
||||
|
||||
async def purge_acknowledged(self, max_age_seconds: int = 86400) -> int:
|
||||
"""Remove old acknowledged messages from the store.
|
||||
|
||||
Returns:
|
||||
Number of messages purged
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Mode protocols and helpers (bind-aware authentication posture)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AuthMode = Literal["local", "token", "password", "trusted-proxy"]
|
||||
"""Authentication mode for gateway/UI components.
|
||||
|
||||
- "local": Permissive mode for loopback interfaces (127.0.0.1, localhost, ::1)
|
||||
- "token": Token-based authentication required (default for external interfaces)
|
||||
- "password": Username/password authentication
|
||||
- "trusted-proxy": Authentication handled by upstream proxy
|
||||
"""
|
||||
|
||||
|
||||
def is_loopback(host: str) -> bool:
|
||||
"""Check if a host is a loopback interface.
|
||||
|
||||
Args:
|
||||
host: Host/IP address to check
|
||||
|
||||
Returns:
|
||||
True if the host is a loopback address
|
||||
|
||||
Examples:
|
||||
>>> is_loopback("127.0.0.1")
|
||||
True
|
||||
>>> is_loopback("localhost")
|
||||
True
|
||||
>>> is_loopback("::1")
|
||||
True
|
||||
>>> is_loopback("0.0.0.0")
|
||||
False
|
||||
>>> is_loopback("192.168.1.1")
|
||||
False
|
||||
"""
|
||||
import ipaddress
|
||||
|
||||
# Handle localhost specially
|
||||
if host in ("localhost", "0:0:0:0:0:0:0:1"):
|
||||
return True
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return ip.is_loopback
|
||||
except ValueError:
|
||||
# Not a valid IP address (e.g., domain name)
|
||||
return False
|
||||
|
||||
|
||||
def resolve_auth_mode(bind_host: str, configured: Optional[AuthMode] = None) -> AuthMode:
|
||||
"""Resolve authentication mode based on bind host and explicit configuration.
|
||||
|
||||
Args:
|
||||
bind_host: Host/IP that the service is bound to
|
||||
configured: Explicitly configured auth mode (takes precedence)
|
||||
|
||||
Returns:
|
||||
The resolved authentication mode
|
||||
|
||||
Examples:
|
||||
>>> resolve_auth_mode("127.0.0.1")
|
||||
'local'
|
||||
>>> resolve_auth_mode("0.0.0.0")
|
||||
'token'
|
||||
>>> resolve_auth_mode("127.0.0.1", "token")
|
||||
'token'
|
||||
"""
|
||||
if configured is not None:
|
||||
return configured
|
||||
|
||||
return "local" if is_loopback(bind_host) else "token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth, Pairing, and Session Binding Protocols (Issue #1588 Gap 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProtocol(Protocol):
|
||||
"""Protocol for authentication implementations.
|
||||
|
||||
Enables structural typing for different authentication strategies
|
||||
(token-based, local loopback, trusted proxy, etc.).
|
||||
"""
|
||||
|
||||
def check(self, request: Any) -> Dict[str, Any]:
|
||||
"""Check authentication for a request.
|
||||
|
||||
Args:
|
||||
request: The request object to authenticate
|
||||
|
||||
Returns:
|
||||
Authentication decision with metadata:
|
||||
- success: bool - whether authentication succeeded
|
||||
- user_id: Optional[str] - authenticated user ID
|
||||
- role: Optional[str] - user role/permissions
|
||||
- metadata: Dict[str, Any] - additional auth context
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PairingProtocol(Protocol):
|
||||
"""Protocol for channel pairing implementations.
|
||||
|
||||
Manages the authorization of external channels (Telegram, Slack, UI)
|
||||
to communicate with the gateway through signed codes.
|
||||
"""
|
||||
|
||||
def generate_code(
|
||||
self,
|
||||
channel_type: str = "unknown",
|
||||
channel_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a new pairing code for a channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel (e.g., "telegram", "slack", "ui")
|
||||
channel_id: Optional channel identifier
|
||||
|
||||
Returns:
|
||||
The generated pairing code
|
||||
"""
|
||||
...
|
||||
|
||||
def approve(
|
||||
self,
|
||||
channel_type: str,
|
||||
code: str,
|
||||
user_id: str = "",
|
||||
user_name: str = ""
|
||||
) -> bool:
|
||||
"""Approve a pairing code, authorizing the channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel
|
||||
code: The pairing code to approve
|
||||
user_id: User identifier (optional, defaults to empty string)
|
||||
user_name: Human-readable username (optional, defaults to empty string)
|
||||
|
||||
Returns:
|
||||
True if approval successful, False if code invalid/expired
|
||||
"""
|
||||
...
|
||||
|
||||
def is_paired(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Check if a channel is authorized.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if channel is paired/authorized
|
||||
"""
|
||||
...
|
||||
|
||||
def list_paired(self) -> List["PairedChannel"]:
|
||||
"""List all authorized channels.
|
||||
|
||||
Returns:
|
||||
List of paired channel information
|
||||
"""
|
||||
...
|
||||
|
||||
def revoke(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Revoke authorization for a channel.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if revocation successful, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def list_pending(self, channel_type: Optional[str] = None) -> List[Dict[str, any]]:
|
||||
"""List pending pairing requests.
|
||||
|
||||
Args:
|
||||
channel_type: Optional filter by channel type
|
||||
|
||||
Returns:
|
||||
List of pending requests with channel, code, user info, and age
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionBindingProtocol(Protocol):
|
||||
"""Protocol for session binding implementations.
|
||||
|
||||
Manages the association between sessions and authenticated principals
|
||||
(users, agents, etc.) for state tracking and authorization.
|
||||
"""
|
||||
|
||||
def bind(self, session_id: str, principal: Dict[str, Any]) -> None:
|
||||
"""Bind a session to an authenticated principal.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
principal: Principal information (user_id, roles, metadata, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
def lookup(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up the principal bound to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier to look up
|
||||
|
||||
Returns:
|
||||
Principal information if found, None otherwise
|
||||
"""
|
||||
...
|
||||
@@ -146,12 +146,12 @@ class HookRunner:
|
||||
loop = None
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
# Already inside a running async loop — cannot use run_until_complete.
|
||||
# Schedule as fire-and-forget task to avoid leaking unawaited coroutines.
|
||||
# Callers that need results in async context should use
|
||||
# ``await runner.execute(...)`` directly.
|
||||
loop.create_task(self.execute(event, input_data, target))
|
||||
return []
|
||||
# Cannot execute sync in running event loop - would block the loop
|
||||
# Guide users to use the async API instead
|
||||
raise RuntimeError(
|
||||
"execute_sync() cannot be called from within a running event loop. "
|
||||
"Use 'await runner.execute(event, input_data, target)' instead in async contexts."
|
||||
)
|
||||
|
||||
# No running loop — safe to create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
|
||||
@@ -101,13 +101,18 @@ class IndexProtocol(Protocol):
|
||||
class IndexRegistry:
|
||||
"""Registry for index types."""
|
||||
|
||||
_instance: Optional["IndexRegistry"] = None
|
||||
def __init__(self):
|
||||
"""Initialize a new registry instance."""
|
||||
self._indices: Dict[str, Callable[..., IndexProtocol]] = {}
|
||||
|
||||
def __new__(cls) -> "IndexRegistry":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._indices: Dict[str, Callable[..., IndexProtocol]] = {}
|
||||
return cls._instance
|
||||
@classmethod
|
||||
def default(cls) -> "IndexRegistry":
|
||||
"""Get a default global registry instance for convenience."""
|
||||
if not hasattr(cls, '_default_instance'):
|
||||
with cls._init_lock:
|
||||
if not hasattr(cls, '_default_instance'):
|
||||
cls._default_instance = cls()
|
||||
return cls._default_instance
|
||||
|
||||
def register(self, name: str, factory: Callable[..., IndexProtocol]) -> None:
|
||||
"""Register an index factory."""
|
||||
@@ -133,7 +138,7 @@ class IndexRegistry:
|
||||
|
||||
def get_index_registry() -> IndexRegistry:
|
||||
"""Get the global index registry instance."""
|
||||
return IndexRegistry()
|
||||
return IndexRegistry.default()
|
||||
|
||||
class KeywordIndex:
|
||||
"""
|
||||
@@ -316,6 +321,10 @@ class KeywordIndex:
|
||||
|
||||
return score
|
||||
|
||||
# Initialize IndexRegistry class lock for thread safety
|
||||
import threading
|
||||
IndexRegistry._init_lock = threading.Lock()
|
||||
|
||||
# Register the keyword index by default
|
||||
def _register_default_indices():
|
||||
"""Register default indices."""
|
||||
|
||||
@@ -9,8 +9,11 @@ import os
|
||||
# Ensure litellm telemetry is disabled before any imports
|
||||
os.environ["LITELLM_TELEMETRY"] = "False"
|
||||
|
||||
import threading
|
||||
|
||||
# Module-level cache for lazy-loaded classes
|
||||
_lazy_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
@@ -18,139 +21,165 @@ def __getattr__(name):
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "LLM":
|
||||
from .llm import LLM
|
||||
_lazy_cache[name] = LLM
|
||||
return LLM
|
||||
elif name == "LLMContextLengthExceededException":
|
||||
from .llm import LLMContextLengthExceededException
|
||||
_lazy_cache[name] = LLMContextLengthExceededException
|
||||
return LLMContextLengthExceededException
|
||||
elif name == "OpenAIClient":
|
||||
from .openai_client import OpenAIClient
|
||||
_lazy_cache[name] = OpenAIClient
|
||||
return OpenAIClient
|
||||
elif name == "get_openai_client":
|
||||
from .openai_client import get_openai_client
|
||||
_lazy_cache[name] = get_openai_client
|
||||
return get_openai_client
|
||||
elif name == "ChatCompletionMessage":
|
||||
from .openai_client import ChatCompletionMessage
|
||||
_lazy_cache[name] = ChatCompletionMessage
|
||||
return ChatCompletionMessage
|
||||
elif name == "Choice":
|
||||
from .openai_client import Choice
|
||||
_lazy_cache[name] = Choice
|
||||
return Choice
|
||||
elif name == "CompletionTokensDetails":
|
||||
from .openai_client import CompletionTokensDetails
|
||||
_lazy_cache[name] = CompletionTokensDetails
|
||||
return CompletionTokensDetails
|
||||
elif name == "PromptTokensDetails":
|
||||
from .openai_client import PromptTokensDetails
|
||||
_lazy_cache[name] = PromptTokensDetails
|
||||
return PromptTokensDetails
|
||||
elif name == "CompletionUsage":
|
||||
from .openai_client import CompletionUsage
|
||||
_lazy_cache[name] = CompletionUsage
|
||||
return CompletionUsage
|
||||
elif name == "ChatCompletion":
|
||||
from .openai_client import ChatCompletion
|
||||
_lazy_cache[name] = ChatCompletion
|
||||
return ChatCompletion
|
||||
elif name == "ToolCall":
|
||||
from .openai_client import ToolCall
|
||||
_lazy_cache[name] = ToolCall
|
||||
return ToolCall
|
||||
elif name == "process_stream_chunks":
|
||||
from .openai_client import process_stream_chunks
|
||||
_lazy_cache[name] = process_stream_chunks
|
||||
return process_stream_chunks
|
||||
elif name == "supports_structured_outputs":
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
_lazy_cache[name] = supports_structured_outputs
|
||||
return supports_structured_outputs
|
||||
elif name == "supports_streaming_with_tools":
|
||||
from .model_capabilities import supports_streaming_with_tools
|
||||
_lazy_cache[name] = supports_streaming_with_tools
|
||||
return supports_streaming_with_tools
|
||||
elif name == "ModelRouter":
|
||||
from .model_router import ModelRouter
|
||||
_lazy_cache[name] = ModelRouter
|
||||
return ModelRouter
|
||||
elif name == "ModelProfile":
|
||||
from .model_router import ModelProfile
|
||||
_lazy_cache[name] = ModelProfile
|
||||
return ModelProfile
|
||||
elif name == "TaskComplexity":
|
||||
from .model_router import TaskComplexity
|
||||
_lazy_cache[name] = TaskComplexity
|
||||
return TaskComplexity
|
||||
elif name == "create_routing_agent":
|
||||
from .model_router import create_routing_agent
|
||||
_lazy_cache[name] = create_routing_agent
|
||||
return create_routing_agent
|
||||
elif name == "RateLimiter":
|
||||
from .rate_limiter import RateLimiter
|
||||
_lazy_cache[name] = RateLimiter
|
||||
return RateLimiter
|
||||
elif name == "TokenUsage":
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
return TokenUsage
|
||||
elif name == "LLMProviderProtocol":
|
||||
from .protocols import LLMProviderProtocol
|
||||
_lazy_cache[name] = LLMProviderProtocol
|
||||
return LLMProviderProtocol
|
||||
elif name == "ModelCapabilitiesProtocol":
|
||||
from .protocols import ModelCapabilitiesProtocol
|
||||
_lazy_cache[name] = ModelCapabilitiesProtocol
|
||||
return ModelCapabilitiesProtocol
|
||||
elif name == "LLMRateLimiterProtocol":
|
||||
from .protocols import LLMRateLimiterProtocol
|
||||
_lazy_cache[name] = LLMRateLimiterProtocol
|
||||
return LLMRateLimiterProtocol
|
||||
elif name == "LLMFailoverProtocol":
|
||||
from .protocols import LLMFailoverProtocol
|
||||
_lazy_cache[name] = LLMFailoverProtocol
|
||||
return LLMFailoverProtocol
|
||||
elif name == "LLMProviderError":
|
||||
from .protocols import LLMProviderError
|
||||
_lazy_cache[name] = LLMProviderError
|
||||
return LLMProviderError
|
||||
elif name == "RateLimitError":
|
||||
from .protocols import RateLimitError
|
||||
_lazy_cache[name] = RateLimitError
|
||||
return RateLimitError
|
||||
elif name == "ModelNotAvailableError":
|
||||
from .protocols import ModelNotAvailableError
|
||||
_lazy_cache[name] = ModelNotAvailableError
|
||||
return ModelNotAvailableError
|
||||
elif name == "ContextLengthExceededError":
|
||||
from .protocols import ContextLengthExceededError
|
||||
_lazy_cache[name] = ContextLengthExceededError
|
||||
return ContextLengthExceededError
|
||||
elif name == "UnifiedLLMProtocol":
|
||||
from .protocols import UnifiedLLMProtocol
|
||||
_lazy_cache[name] = UnifiedLLMProtocol
|
||||
return UnifiedLLMProtocol
|
||||
elif name == "LiteLLMAdapter":
|
||||
from .unified_adapters import LiteLLMAdapter
|
||||
_lazy_cache[name] = LiteLLMAdapter
|
||||
return LiteLLMAdapter
|
||||
elif name == "OpenAIAdapter":
|
||||
from .unified_adapters import OpenAIAdapter
|
||||
_lazy_cache[name] = OpenAIAdapter
|
||||
return OpenAIAdapter
|
||||
elif name == "UnifiedLLMDispatcher":
|
||||
from .unified_adapters import UnifiedLLMDispatcher
|
||||
_lazy_cache[name] = UnifiedLLMDispatcher
|
||||
return UnifiedLLMDispatcher
|
||||
elif name == "create_llm_dispatcher":
|
||||
from .unified_adapters import create_llm_dispatcher
|
||||
_lazy_cache[name] = create_llm_dispatcher
|
||||
return create_llm_dispatcher
|
||||
|
||||
with _cache_lock:
|
||||
# Double-check after acquiring lock
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "LLM":
|
||||
from .llm import LLM
|
||||
_lazy_cache[name] = LLM
|
||||
return LLM
|
||||
elif name == "LLMContextLengthExceededException":
|
||||
from .llm import LLMContextLengthExceededException
|
||||
_lazy_cache[name] = LLMContextLengthExceededException
|
||||
return LLMContextLengthExceededException
|
||||
|
||||
elif name == "OpenAIClient":
|
||||
from .openai_client import OpenAIClient
|
||||
_lazy_cache[name] = OpenAIClient
|
||||
return OpenAIClient
|
||||
elif name == "get_openai_client":
|
||||
from .openai_client import get_openai_client
|
||||
_lazy_cache[name] = get_openai_client
|
||||
return get_openai_client
|
||||
elif name == "ChatCompletionMessage":
|
||||
from .openai_client import ChatCompletionMessage
|
||||
_lazy_cache[name] = ChatCompletionMessage
|
||||
return ChatCompletionMessage
|
||||
elif name == "Choice":
|
||||
from .openai_client import Choice
|
||||
_lazy_cache[name] = Choice
|
||||
return Choice
|
||||
elif name == "CompletionTokensDetails":
|
||||
from .openai_client import CompletionTokensDetails
|
||||
_lazy_cache[name] = CompletionTokensDetails
|
||||
return CompletionTokensDetails
|
||||
elif name == "PromptTokensDetails":
|
||||
from .openai_client import PromptTokensDetails
|
||||
_lazy_cache[name] = PromptTokensDetails
|
||||
return PromptTokensDetails
|
||||
elif name == "CompletionUsage":
|
||||
from .openai_client import CompletionUsage
|
||||
_lazy_cache[name] = CompletionUsage
|
||||
return CompletionUsage
|
||||
elif name == "ChatCompletion":
|
||||
from .openai_client import ChatCompletion
|
||||
_lazy_cache[name] = ChatCompletion
|
||||
return ChatCompletion
|
||||
elif name == "ToolCall":
|
||||
from .openai_client import ToolCall
|
||||
_lazy_cache[name] = ToolCall
|
||||
return ToolCall
|
||||
elif name == "process_stream_chunks":
|
||||
from .openai_client import process_stream_chunks
|
||||
_lazy_cache[name] = process_stream_chunks
|
||||
return process_stream_chunks
|
||||
elif name == "supports_structured_outputs":
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
_lazy_cache[name] = supports_structured_outputs
|
||||
return supports_structured_outputs
|
||||
elif name == "supports_streaming_with_tools":
|
||||
from .model_capabilities import supports_streaming_with_tools
|
||||
_lazy_cache[name] = supports_streaming_with_tools
|
||||
return supports_streaming_with_tools
|
||||
elif name == "ModelRouter":
|
||||
from .model_router import ModelRouter
|
||||
_lazy_cache[name] = ModelRouter
|
||||
return ModelRouter
|
||||
elif name == "ModelProfile":
|
||||
from .model_router import ModelProfile
|
||||
_lazy_cache[name] = ModelProfile
|
||||
return ModelProfile
|
||||
elif name == "TaskComplexity":
|
||||
from .model_router import TaskComplexity
|
||||
_lazy_cache[name] = TaskComplexity
|
||||
return TaskComplexity
|
||||
elif name == "create_routing_agent":
|
||||
from .model_router import create_routing_agent
|
||||
_lazy_cache[name] = create_routing_agent
|
||||
return create_routing_agent
|
||||
elif name == "RateLimiter":
|
||||
from .rate_limiter import RateLimiter
|
||||
_lazy_cache[name] = RateLimiter
|
||||
return RateLimiter
|
||||
elif name == "TokenUsage":
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
return TokenUsage
|
||||
elif name == "LLMProviderProtocol":
|
||||
from .protocols import LLMProviderProtocol
|
||||
_lazy_cache[name] = LLMProviderProtocol
|
||||
return LLMProviderProtocol
|
||||
elif name == "ModelCapabilitiesProtocol":
|
||||
from .protocols import ModelCapabilitiesProtocol
|
||||
_lazy_cache[name] = ModelCapabilitiesProtocol
|
||||
return ModelCapabilitiesProtocol
|
||||
elif name == "LLMRateLimiterProtocol":
|
||||
from .protocols import LLMRateLimiterProtocol
|
||||
_lazy_cache[name] = LLMRateLimiterProtocol
|
||||
return LLMRateLimiterProtocol
|
||||
elif name == "LLMFailoverProtocol":
|
||||
from .protocols import LLMFailoverProtocol
|
||||
_lazy_cache[name] = LLMFailoverProtocol
|
||||
return LLMFailoverProtocol
|
||||
elif name == "LLMProviderError":
|
||||
from .protocols import LLMProviderError
|
||||
_lazy_cache[name] = LLMProviderError
|
||||
return LLMProviderError
|
||||
elif name == "RateLimitError":
|
||||
from .protocols import RateLimitError
|
||||
_lazy_cache[name] = RateLimitError
|
||||
return RateLimitError
|
||||
elif name == "ModelNotAvailableError":
|
||||
from .protocols import ModelNotAvailableError
|
||||
_lazy_cache[name] = ModelNotAvailableError
|
||||
return ModelNotAvailableError
|
||||
elif name == "ContextLengthExceededError":
|
||||
from .protocols import ContextLengthExceededError
|
||||
_lazy_cache[name] = ContextLengthExceededError
|
||||
return ContextLengthExceededError
|
||||
elif name == "UnifiedLLMProtocol":
|
||||
from .protocols import UnifiedLLMProtocol
|
||||
_lazy_cache[name] = UnifiedLLMProtocol
|
||||
return UnifiedLLMProtocol
|
||||
elif name == "LiteLLMAdapter":
|
||||
from .unified_adapters import LiteLLMAdapter
|
||||
_lazy_cache[name] = LiteLLMAdapter
|
||||
return LiteLLMAdapter
|
||||
elif name == "OpenAIAdapter":
|
||||
from .unified_adapters import OpenAIAdapter
|
||||
_lazy_cache[name] = OpenAIAdapter
|
||||
return OpenAIAdapter
|
||||
elif name == "UnifiedLLMDispatcher":
|
||||
from .unified_adapters import UnifiedLLMDispatcher
|
||||
_lazy_cache[name] = UnifiedLLMDispatcher
|
||||
return UnifiedLLMDispatcher
|
||||
elif name == "create_llm_dispatcher":
|
||||
from .unified_adapters import create_llm_dispatcher
|
||||
_lazy_cache[name] = create_llm_dispatcher
|
||||
return create_llm_dispatcher
|
||||
elif name == "sanitize_messages":
|
||||
from .sanitize import sanitize_messages
|
||||
_lazy_cache[name] = sanitize_messages
|
||||
return sanitize_messages
|
||||
elif name == "strip_surrogates":
|
||||
from .sanitize import strip_surrogates
|
||||
_lazy_cache[name] = strip_surrogates
|
||||
return strip_surrogates
|
||||
elif name == "sanitize_text":
|
||||
from .sanitize import sanitize_text
|
||||
_lazy_cache[name] = sanitize_text
|
||||
return sanitize_text
|
||||
elif name == "ErrorCategory":
|
||||
from .error_classifier import ErrorCategory
|
||||
_lazy_cache[name] = ErrorCategory
|
||||
return ErrorCategory
|
||||
elif name == "classify_error":
|
||||
from .error_classifier import classify_error
|
||||
_lazy_cache[name] = classify_error
|
||||
return classify_error
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -190,5 +219,12 @@ __all__ = [
|
||||
"LLMProviderError",
|
||||
"RateLimitError",
|
||||
"ModelNotAvailableError",
|
||||
"ContextLengthExceededError"
|
||||
"ContextLengthExceededError",
|
||||
# Sanitization (G6)
|
||||
"sanitize_messages",
|
||||
"strip_surrogates",
|
||||
"sanitize_text",
|
||||
# Error Classification (G5)
|
||||
"ErrorCategory",
|
||||
"classify_error"
|
||||
]
|
||||
@@ -4,11 +4,13 @@ LLM Provider Adapters
|
||||
Concrete implementations of LLMProviderAdapter protocol that replace
|
||||
scattered provider dispatch logic throughout the core.
|
||||
|
||||
This demonstrates the protocol-driven approach for Gap 2.
|
||||
This demonstrates the protocol-driven approach for Gap 3 (streaming)
|
||||
and integrates with Gap 2 (parallel tool execution).
|
||||
"""
|
||||
|
||||
from ..protocols import LLMProviderAdapterProtocol
|
||||
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
from ..streaming_protocol import StreamingCapableAdapter, get_streaming_adapter
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
@@ -36,6 +38,11 @@ class DefaultAdapter:
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
return True # Most providers support streaming with tools
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get the streaming adapter for this provider."""
|
||||
# Default providers use the default streaming adapter
|
||||
return get_streaming_adapter("default")
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 10 # Conservative default
|
||||
|
||||
@@ -57,6 +64,25 @@ class DefaultAdapter:
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {} # No provider-specific defaults
|
||||
|
||||
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Default tool call parsing - use OpenAI-style format."""
|
||||
if "choices" in raw_response and len(raw_response["choices"]) > 0:
|
||||
message = raw_response["choices"][0].get("message", {})
|
||||
return message.get("tool_calls")
|
||||
return None
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
return False # Most providers support streaming with tools
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
return None # No text recovery by default
|
||||
|
||||
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return messages # No cache control by default
|
||||
|
||||
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
|
||||
return 0 # No reasoning tokens by default
|
||||
|
||||
|
||||
class OllamaAdapter(DefaultAdapter):
|
||||
@@ -79,6 +105,10 @@ class OllamaAdapter(DefaultAdapter):
|
||||
# Ollama doesn't reliably support streaming with tools
|
||||
return False
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Ollama-specific streaming adapter."""
|
||||
return get_streaming_adapter("ollama")
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 1 # Ollama-specific threshold
|
||||
|
||||
@@ -99,6 +129,37 @@ class OllamaAdapter(DefaultAdapter):
|
||||
return True # Signal that special handling is needed
|
||||
return False
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Ollama-specific tool call recovery from response text."""
|
||||
if not response_text or not tools:
|
||||
return None
|
||||
|
||||
try:
|
||||
import json
|
||||
response_json = json.loads(response_text.strip())
|
||||
|
||||
# Normalize to list so both single and multi-tool payloads are supported
|
||||
if isinstance(response_json, dict):
|
||||
response_json = [response_json]
|
||||
|
||||
if isinstance(response_json, list):
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
return tool_calls if tool_calls else None
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
# Replaces: Ollama-specific post-tool summary branches
|
||||
if (not state.get('response_text', '').strip() and
|
||||
@@ -130,6 +191,10 @@ class AnthropicAdapter(DefaultAdapter):
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Anthropic-specific streaming adapter."""
|
||||
return get_streaming_adapter("anthropic")
|
||||
|
||||
|
||||
class GeminiAdapter(DefaultAdapter):
|
||||
@@ -142,6 +207,13 @@ class GeminiAdapter(DefaultAdapter):
|
||||
- Supports structured output
|
||||
"""
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
"""Gemini should skip streaming when tools are present."""
|
||||
return True
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Replaces: gemini_internal_tools handling in llm.py
|
||||
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
@@ -161,8 +233,9 @@ class GeminiAdapter(DefaultAdapter):
|
||||
# Gemini has issues with streaming + tools
|
||||
return False
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Gemini-specific streaming adapter."""
|
||||
return get_streaming_adapter("gemini")
|
||||
|
||||
|
||||
# Provider adapter registry - public for extension
|
||||
@@ -236,4 +309,4 @@ __all__ = [
|
||||
'add_provider_adapter',
|
||||
'list_provider_adapters',
|
||||
'has_provider_adapter',
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Error Classification - Multi-category error classifier for intelligent retry logic.
|
||||
|
||||
Extends the existing single-category rate limit detection with comprehensive
|
||||
error classification for better handling of different failure modes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import random
|
||||
from enum import Enum
|
||||
from typing import Dict, Tuple, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"ErrorCategory",
|
||||
"classify_error",
|
||||
"should_retry",
|
||||
"get_retry_delay",
|
||||
"extract_retry_after",
|
||||
"get_error_context",
|
||||
]
|
||||
|
||||
|
||||
class ErrorCategory(str, Enum):
|
||||
"""Categories of LLM provider errors for intelligent handling."""
|
||||
RATE_LIMIT = "rate_limit" # Too many requests, temporary
|
||||
CONTEXT_LIMIT = "context_limit" # Input too long, need compression
|
||||
AUTH = "auth" # Authentication/authorization failure
|
||||
INVALID_REQUEST = "invalid_request" # Malformed request, permanent
|
||||
TRANSIENT = "transient" # Network/server issues, temporary
|
||||
PERMANENT = "permanent" # Unrecoverable error
|
||||
|
||||
|
||||
# Error patterns for classification (case-insensitive)
|
||||
_ERROR_PATTERNS: Dict[ErrorCategory, List[str]] = {
|
||||
ErrorCategory.RATE_LIMIT: [
|
||||
r"rate.?limit",
|
||||
r"429",
|
||||
r"too.?many.?request",
|
||||
r"resource.?exhausted",
|
||||
r"quota.?exceeded",
|
||||
r"tokens.?per.?minute",
|
||||
r"requests.?per.?minute",
|
||||
r"concurrent.?requests",
|
||||
],
|
||||
|
||||
ErrorCategory.CONTEXT_LIMIT: [
|
||||
r"context.?length",
|
||||
r"maximum.?context",
|
||||
r"token.?limit",
|
||||
r"input.?too.?long",
|
||||
r"sequence.?too.?long",
|
||||
r"context.?window",
|
||||
r"413", # Request Entity Too Large
|
||||
r"payload.?too.?large",
|
||||
],
|
||||
|
||||
ErrorCategory.AUTH: [
|
||||
r"authenticat",
|
||||
r"authoriz",
|
||||
r"401",
|
||||
r"403",
|
||||
r"invalid.?api.?key",
|
||||
r"api.?key.*invalid",
|
||||
r"permission.?denied",
|
||||
r"access.?denied",
|
||||
r"forbidden",
|
||||
r"unauthorized",
|
||||
r"invalid.?token",
|
||||
r"expired.*token",
|
||||
],
|
||||
|
||||
ErrorCategory.INVALID_REQUEST: [
|
||||
r"invalid.*request",
|
||||
r"bad.?request",
|
||||
r"400",
|
||||
r"malformed",
|
||||
r"invalid.*parameter",
|
||||
r"unsupported.*model",
|
||||
r"model.*not.*found",
|
||||
r"validation.*error",
|
||||
r"schema.*error",
|
||||
],
|
||||
|
||||
ErrorCategory.TRANSIENT: [
|
||||
r"timeout",
|
||||
r"timed.?out",
|
||||
r"500", r"502", r"503", r"504",
|
||||
r"internal.?server.?error",
|
||||
r"bad.?gateway",
|
||||
r"service.?unavailable",
|
||||
r"gateway.?timeout",
|
||||
r"connection.*error",
|
||||
r"network.*error",
|
||||
r"temporary.*unavailable",
|
||||
r"server.?overload",
|
||||
r"retry.?after",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def classify_error(error: Exception) -> ErrorCategory:
|
||||
"""Classify an error into a category for intelligent handling.
|
||||
|
||||
Args:
|
||||
error: Exception to classify
|
||||
|
||||
Returns:
|
||||
ErrorCategory indicating how the error should be handled
|
||||
|
||||
Examples:
|
||||
>>> classify_error(Exception("Rate limit exceeded"))
|
||||
ErrorCategory.RATE_LIMIT
|
||||
>>> classify_error(Exception("Context length 8192 exceeded"))
|
||||
ErrorCategory.CONTEXT_LIMIT
|
||||
>>> classify_error(Exception("Invalid API key"))
|
||||
ErrorCategory.AUTH
|
||||
"""
|
||||
error_text = f"{type(error).__name__} {error}".lower()
|
||||
|
||||
# Check each category's patterns
|
||||
for category, patterns in _ERROR_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, error_text, re.IGNORECASE):
|
||||
return category
|
||||
|
||||
# Default to permanent for unknown errors
|
||||
return ErrorCategory.PERMANENT
|
||||
|
||||
|
||||
def should_retry(category: ErrorCategory) -> bool:
|
||||
"""Determine if an error category should be retried.
|
||||
|
||||
Args:
|
||||
category: Error category from classify_error()
|
||||
|
||||
Returns:
|
||||
True if the error type should be retried
|
||||
"""
|
||||
return category in {
|
||||
ErrorCategory.RATE_LIMIT,
|
||||
ErrorCategory.CONTEXT_LIMIT, # Could retry with compression
|
||||
ErrorCategory.TRANSIENT,
|
||||
}
|
||||
|
||||
|
||||
def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float = 1.0) -> float:
|
||||
"""Get the appropriate delay before retrying based on error category.
|
||||
|
||||
Uses full jitter to prevent thundering herd problems in multi-agent setups
|
||||
where multiple agents hit rate limits simultaneously.
|
||||
|
||||
Args:
|
||||
category: Error category
|
||||
attempt: Current attempt number (1-based)
|
||||
base_delay: Base delay in seconds
|
||||
|
||||
Returns:
|
||||
Delay in seconds, or 0 if should not retry
|
||||
|
||||
Examples:
|
||||
>>> # With jitter, these will return random values in range:
|
||||
>>> get_retry_delay(ErrorCategory.RATE_LIMIT, attempt=1) # 0.0 to 3.0
|
||||
>>> get_retry_delay(ErrorCategory.TRANSIENT, attempt=3) # 0.0 to 8.0
|
||||
>>> get_retry_delay(ErrorCategory.AUTH, attempt=1) # Always 0
|
||||
"""
|
||||
attempt = max(1, attempt)
|
||||
|
||||
if not should_retry(category):
|
||||
return 0
|
||||
|
||||
if category == ErrorCategory.RATE_LIMIT:
|
||||
# Exponential backoff with equal jitter for rate limits (minimum floor to prevent instant retries)
|
||||
max_delay = min(base_delay * (3 ** attempt), 60.0)
|
||||
return base_delay + random.uniform(0, max_delay - base_delay)
|
||||
|
||||
elif category == ErrorCategory.CONTEXT_LIMIT:
|
||||
# Short delay for context limits (no jitter needed - not a contention issue)
|
||||
return base_delay * 0.5
|
||||
|
||||
elif category == ErrorCategory.TRANSIENT:
|
||||
# Exponential backoff with equal jitter for transient errors (minimum floor to prevent instant retries)
|
||||
max_delay = min(base_delay * (2 ** attempt), 30.0)
|
||||
return base_delay + random.uniform(0, max_delay - base_delay)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_retry_after(error: Exception) -> Optional[float]:
|
||||
"""Extract Retry-After header value from rate limit errors.
|
||||
|
||||
Args:
|
||||
error: Exception potentially containing Retry-After info
|
||||
|
||||
Returns:
|
||||
Delay in seconds if found, None otherwise
|
||||
"""
|
||||
error_str = str(error)
|
||||
|
||||
# Look for common Retry-After patterns
|
||||
patterns = [
|
||||
r"retry.?after[:\s]+(\d+)",
|
||||
r"retry[:\s]+(\d+)",
|
||||
r"wait[:\s]+(\d+)",
|
||||
r"(\d+).*second",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, error_str, re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
delay = float(match.group(1))
|
||||
return min(delay, 300.0) # Cap at 5 minutes
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_error_context(error: Exception) -> Dict[str, str]:
|
||||
"""Extract structured context from an error for logging/debugging.
|
||||
|
||||
Args:
|
||||
error: Exception to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with error context information
|
||||
"""
|
||||
category = classify_error(error)
|
||||
|
||||
context = {
|
||||
"error_type": type(error).__name__,
|
||||
"category": category.value,
|
||||
"should_retry": str(should_retry(category)),
|
||||
"message": str(error)[:500], # Truncate long messages
|
||||
}
|
||||
|
||||
# Add category-specific context
|
||||
if category == ErrorCategory.RATE_LIMIT:
|
||||
retry_after = extract_retry_after(error)
|
||||
if retry_after:
|
||||
context["retry_after"] = str(retry_after)
|
||||
|
||||
elif category == ErrorCategory.CONTEXT_LIMIT:
|
||||
context["suggestion"] = "Try reducing input size or enabling compression"
|
||||
|
||||
elif category == ErrorCategory.AUTH:
|
||||
context["suggestion"] = "Check API key configuration"
|
||||
|
||||
elif category == ErrorCategory.INVALID_REQUEST:
|
||||
context["suggestion"] = "Review request parameters"
|
||||
|
||||
return context
|
||||
@@ -6,15 +6,23 @@ import re
|
||||
import inspect
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Union, Literal, Callable, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, Optional, Union, Literal, Callable, TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
|
||||
class FailoverManagerProtocol(Protocol):
|
||||
"""Protocol for failover manager implementations."""
|
||||
def get_next_profile(self) -> Optional["AuthProfile"]: ...
|
||||
def mark_failure(self, profile: "AuthProfile", error: str, is_rate_limit: bool = False) -> None: ...
|
||||
def mark_success(self, profile: "AuthProfile") -> None: ...
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
# Gap 2: Tool call execution imports
|
||||
from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
# Display functions - lazy loaded to avoid importing rich at startup
|
||||
# These are only needed when output=verbose
|
||||
_display_module = None
|
||||
@@ -352,6 +360,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
web_fetch: Optional[Union[bool, Dict[str, Any]]] = None,
|
||||
prompt_caching: Optional[bool] = None,
|
||||
claude_memory: Optional[Union[bool, Any]] = None,
|
||||
failover_manager: Optional[FailoverManagerProtocol] = None,
|
||||
**extra_settings
|
||||
):
|
||||
# Configure logging only once at the class level
|
||||
@@ -427,6 +436,14 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._rate_limiter = extra_settings.get('rate_limiter', None)
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Failover management
|
||||
self._failover_manager = failover_manager
|
||||
self._current_profile = None # Track current auth profile for failover
|
||||
if self._failover_manager:
|
||||
self._current_profile = self._failover_manager.get_next_profile()
|
||||
if self._current_profile:
|
||||
self._switch_to_profile(self._current_profile)
|
||||
|
||||
# Cache for formatted tools and messages
|
||||
self._formatted_tools_cache = {}
|
||||
@@ -648,8 +665,58 @@ 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(self, error: Exception, attempt: int = 1) -> tuple[str, bool, float]:
|
||||
"""Classify error and determine retry strategy using G5 error classifier.
|
||||
|
||||
Args:
|
||||
error: Exception to classify
|
||||
attempt: Current attempt number (1-based)
|
||||
|
||||
Returns:
|
||||
Tuple of (category, should_retry, retry_delay)
|
||||
"""
|
||||
try:
|
||||
from .error_classifier import classify_error, should_retry, get_retry_delay, extract_retry_after
|
||||
|
||||
category = classify_error(error)
|
||||
can_retry = should_retry(category)
|
||||
|
||||
if not can_retry:
|
||||
return category.value, False, 0.0
|
||||
|
||||
# For rate limits, try to extract specific retry-after first
|
||||
if category.value == "rate_limit":
|
||||
retry_after = extract_retry_after(error)
|
||||
if retry_after:
|
||||
return category.value, True, retry_after
|
||||
|
||||
# Use category-specific delay calculation with proper attempt
|
||||
delay = get_retry_delay(category, attempt=attempt, base_delay=self._retry_delay)
|
||||
return category.value, True, delay
|
||||
|
||||
except ImportError:
|
||||
# Fallback to legacy rate limit detection
|
||||
is_rate_limit = self._is_rate_limit_error(error)
|
||||
delay = self._parse_retry_delay(str(error)) if is_rate_limit else 0.0
|
||||
return "rate_limit" if is_rate_limit else "unknown", is_rate_limit, delay
|
||||
|
||||
def _switch_to_profile(self, profile: "AuthProfile") -> None:
|
||||
"""Switch to a new auth profile for failover.
|
||||
|
||||
Args:
|
||||
profile: AuthProfile to switch to
|
||||
"""
|
||||
if profile.api_key:
|
||||
self.api_key = profile.api_key
|
||||
if profile.base_url:
|
||||
self.base_url = profile.base_url
|
||||
if profile.model and profile.model != self.model:
|
||||
# Only log if model actually changes
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors.
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
|
||||
Args:
|
||||
func: The function to call (e.g., litellm.completion)
|
||||
@@ -670,20 +737,48 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
|
||||
return func(*args, **kwargs)
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
category, can_retry, retry_delay = self._classify_error_and_should_retry(e, attempt + 1)
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
# Enable retry for profile switch even if originally non-retryable
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
|
||||
if not can_retry:
|
||||
raise
|
||||
|
||||
if attempt < self._max_retries:
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"{category} error hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
@@ -710,7 +805,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
raise last_error
|
||||
|
||||
async def _call_with_retry_async(self, func, *args, **kwargs):
|
||||
"""Async version of _call_with_retry.
|
||||
"""Async version of _call_with_retry with failover support.
|
||||
|
||||
Args:
|
||||
func: The async function to call
|
||||
@@ -731,20 +826,48 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
category, can_retry, retry_delay = self._classify_error_and_should_retry(e, attempt + 1)
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
# Enable retry for profile switch even if originally non-retryable
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
|
||||
if not can_retry:
|
||||
raise
|
||||
|
||||
if attempt < self._max_retries:
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"{category} error hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
@@ -1649,6 +1772,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
parallel_tool_calls: bool = False, # Gap 2: Enable parallel tool execution
|
||||
stream: bool = True,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
@@ -1893,26 +2017,47 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
tool_results = []
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
|
||||
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama=is_ollama)
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
tool_call_id=tool_call_id,
|
||||
is_ollama=is_ollama
|
||||
))
|
||||
|
||||
# Create appropriate executor based on parallel_tool_calls setting
|
||||
executor = create_tool_call_executor(parallel=parallel_tool_calls)
|
||||
|
||||
# Execute batch
|
||||
tool_results_batch = executor.execute_batch(tool_calls_batch, execute_tool_fn)
|
||||
|
||||
tool_results = []
|
||||
for tool_call_obj, tool_result_obj in zip(tool_calls_batch, tool_results_batch):
|
||||
if tool_result_obj.error is not None:
|
||||
raise tool_result_obj.error
|
||||
tool_result = tool_result_obj.result
|
||||
tool_results.append(tool_result)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
logging.debug(f"[RESPONSES_API] Executed tool {tool_result_obj.function_name} with result: {tool_result}")
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message = f"Agent {agent_name} called function '{tool_call_obj.function_name}' with arguments: {tool_call_obj.arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
result_str = json.dumps(tool_result) if tool_result else "empty"
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {function_name}",
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
message=f"Calling function: {tool_call_obj.function_name}",
|
||||
tool_name=tool_call_obj.function_name,
|
||||
tool_input=tool_call_obj.arguments,
|
||||
tool_output=result_str[:200] if result_str else None,
|
||||
)
|
||||
|
||||
@@ -1927,7 +2072,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_call_id": tool_result_obj.tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
@@ -3142,6 +3287,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
parallel_tool_calls: bool = False, # Gap 2: Enable parallel tool execution
|
||||
**kwargs
|
||||
):
|
||||
"""Generator that yields real-time response chunks from the LLM.
|
||||
@@ -3167,6 +3313,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_description: Optional task description for logging
|
||||
task_id: Optional task ID for logging
|
||||
execute_tool_fn: Optional function for executing tools
|
||||
parallel_tool_calls: If True, execute batched LLM tool calls in parallel (default False)
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Yields:
|
||||
@@ -3301,30 +3448,48 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"tool_calls": serializable_tool_calls
|
||||
})
|
||||
|
||||
# Execute tool calls and add results to conversation
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
is_ollama = self._is_ollama_provider()
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
try:
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
|
||||
# Add tool result to messages
|
||||
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
|
||||
messages.append(tool_message)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Tool execution error for {function_name}: {e}")
|
||||
# Add error message to conversation
|
||||
error_message = self._create_tool_message(
|
||||
function_name, f"Error executing tool: {e}", tool_call_id, is_ollama
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
tool_call_id=tool_call_id,
|
||||
is_ollama=is_ollama
|
||||
))
|
||||
|
||||
# Create appropriate executor based on parallel_tool_calls setting
|
||||
executor = create_tool_call_executor(parallel=parallel_tool_calls)
|
||||
|
||||
# Execute batch and add results to conversation
|
||||
tool_results = executor.execute_batch(tool_calls_batch, execute_tool_fn)
|
||||
|
||||
for tool_result in tool_results:
|
||||
if tool_result.error is None:
|
||||
# Successful execution
|
||||
tool_message = self._create_tool_message(
|
||||
tool_result.function_name,
|
||||
tool_result.result,
|
||||
tool_result.tool_call_id,
|
||||
tool_result.is_ollama
|
||||
)
|
||||
messages.append(error_message)
|
||||
else:
|
||||
# Error during execution (already logged by executor)
|
||||
tool_message = self._create_tool_message(
|
||||
tool_result.function_name,
|
||||
tool_result.result, # Contains error message
|
||||
tool_result.tool_call_id,
|
||||
tool_result.is_ollama
|
||||
)
|
||||
messages.append(tool_message)
|
||||
|
||||
# Continue conversation after tool execution - get follow-up response
|
||||
try:
|
||||
follow_up_response = litellm.completion(
|
||||
follow_up_response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -3340,7 +3505,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Yield the follow-up response after tool execution
|
||||
yield follow_up_content
|
||||
except Exception as e:
|
||||
logging.error(f"Follow-up response failed: {e}")
|
||||
import time
|
||||
error_ref = f"followup-{int(time.time() * 1000)}"
|
||||
logging.error(
|
||||
"Follow-up response failed after retries (ref=%s, model=%s): %s",
|
||||
error_ref, getattr(self, 'model', 'unknown'), e
|
||||
)
|
||||
yield (
|
||||
f"\n\n[Error: Failed to generate final response after tool execution "
|
||||
f"(ref: {error_ref}). Please retry. If it continues, try reducing prompt size.]"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
@@ -3359,7 +3533,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not use_streaming:
|
||||
# Fall back to non-streaming and yield the complete response
|
||||
try:
|
||||
response = litellm.completion(
|
||||
response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -5462,4 +5636,4 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
}
|
||||
}
|
||||
logging.debug(f"Generated tool definition: {tool_def}")
|
||||
return tool_def
|
||||
return tool_def
|
||||
@@ -326,6 +326,26 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Get provider-specific default settings."""
|
||||
...
|
||||
|
||||
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Parse tool calls from provider-specific response format."""
|
||||
...
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
"""Check if provider should skip streaming when tools are present."""
|
||||
...
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Attempt to recover tool calls from response text for providers that don't format them properly."""
|
||||
...
|
||||
|
||||
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Inject provider-specific cache control headers."""
|
||||
...
|
||||
|
||||
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
|
||||
"""Extract reasoning token count from provider-specific response."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -24,6 +24,7 @@ Usage:
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable
|
||||
@@ -67,11 +68,15 @@ class RateLimiter:
|
||||
_tokens: float = field(default=None, init=False, repr=False)
|
||||
_last_update: float = field(default=None, init=False, repr=False)
|
||||
_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
_lock_init: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
_sync_lock: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
|
||||
# Internal state for token-based limiting
|
||||
_api_tokens: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_last_update: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
_api_tokens_lock_init: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
_sync_api_tokens_lock: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
|
||||
# Injectable functions for testing
|
||||
_get_time: Callable[[], float] = field(default=None, init=False, repr=False)
|
||||
@@ -88,6 +93,9 @@ class RateLimiter:
|
||||
self._last_update = time.monotonic()
|
||||
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
|
||||
self._lock = None
|
||||
if self._lock_init is None:
|
||||
self._lock_init = threading.Lock() # Threading lock for async lock initialization
|
||||
self._sync_lock = threading.Lock()
|
||||
|
||||
# Token-based limiting
|
||||
if self.tokens_per_minute is not None:
|
||||
@@ -98,6 +106,9 @@ class RateLimiter:
|
||||
self._api_tokens_last_update = time.monotonic()
|
||||
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
|
||||
self._api_tokens_lock = None
|
||||
if self._api_tokens_lock_init is None:
|
||||
self._api_tokens_lock_init = threading.Lock() # Threading lock for async lock initialization
|
||||
self._sync_api_tokens_lock = threading.Lock()
|
||||
|
||||
# Default implementations (can be overridden for testing)
|
||||
if self._get_time is None:
|
||||
@@ -110,13 +121,17 @@ class RateLimiter:
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Get or create the request lock lazily (Python 3.9 compatible)."""
|
||||
if self._lock is None:
|
||||
self._lock = asyncio.Lock()
|
||||
with self._lock_init: # Thread-safe initialization
|
||||
if self._lock is None: # Double-check after acquiring lock
|
||||
self._lock = asyncio.Lock()
|
||||
return self._lock
|
||||
|
||||
def _get_api_tokens_lock(self) -> asyncio.Lock:
|
||||
"""Get or create the API tokens lock lazily (Python 3.9 compatible)."""
|
||||
if self._api_tokens_lock is None:
|
||||
self._api_tokens_lock = asyncio.Lock()
|
||||
with self._api_tokens_lock_init: # Thread-safe initialization
|
||||
if self._api_tokens_lock is None: # Double-check after acquiring lock
|
||||
self._api_tokens_lock = asyncio.Lock()
|
||||
return self._api_tokens_lock
|
||||
|
||||
@property
|
||||
@@ -173,15 +188,15 @@ class RateLimiter:
|
||||
if self.requests_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill()
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
while True:
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait = self._wait_time()
|
||||
logger.debug(f"Rate limit: waiting {wait:.2f}s before next request")
|
||||
self._sleep(wait)
|
||||
self._refill()
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
def acquire_tokens(self, num_tokens: int) -> None:
|
||||
"""Acquire API tokens, blocking if necessary.
|
||||
@@ -197,17 +212,17 @@ class RateLimiter:
|
||||
if self.tokens_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill_api_tokens()
|
||||
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
if wait > 0:
|
||||
while True:
|
||||
with self._sync_api_tokens_lock:
|
||||
self._refill_api_tokens()
|
||||
if self._api_tokens >= num_tokens:
|
||||
self._api_tokens -= num_tokens
|
||||
return
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
# Do NOT cap internal wait time - this ensures correct rate limiting
|
||||
# The wait time is calculated based on token refill rate and must be honored
|
||||
logger.debug(f"Token limit: waiting {wait:.2f}s for {num_tokens} tokens")
|
||||
self._sleep(wait)
|
||||
self._refill_api_tokens()
|
||||
|
||||
self._api_tokens -= num_tokens
|
||||
|
||||
async def acquire_async(self) -> None:
|
||||
"""Acquire a request token asynchronously, waiting if necessary.
|
||||
@@ -263,23 +278,24 @@ class RateLimiter:
|
||||
if self.requests_per_minute is None:
|
||||
return True
|
||||
|
||||
self._refill()
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rate limiter to initial state."""
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
self._api_tokens_last_update = self._get_time()
|
||||
with self._sync_lock:
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
with self._sync_api_tokens_lock:
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
self._api_tokens_last_update = self._get_time()
|
||||
|
||||
def wait_for_retry(self, retry_delay: float) -> None:
|
||||
"""Wait for a specified retry delay (for handling 429 errors).
|
||||
@@ -304,14 +320,16 @@ class RateLimiter:
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
"""Get current available request tokens (for monitoring)."""
|
||||
self._refill()
|
||||
return self._tokens
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
return self._tokens
|
||||
|
||||
@property
|
||||
def available_api_tokens(self) -> float:
|
||||
"""Get current available API tokens (for monitoring)."""
|
||||
self._refill_api_tokens()
|
||||
return self._api_tokens
|
||||
with self._sync_api_tokens_lock:
|
||||
self._refill_api_tokens()
|
||||
return self._api_tokens
|
||||
|
||||
def __repr__(self) -> str:
|
||||
parts = []
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Message Sanitization - Clean surrogate/non-ASCII characters before LLM calls.
|
||||
|
||||
Prevents Unicode encoding issues that can cause silent failures with some
|
||||
providers when processing emoji, non-Latin text, or corrupted Unicode.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
__all__ = ["sanitize_messages", "strip_surrogates"]
|
||||
|
||||
|
||||
def strip_surrogates(text: str) -> str:
|
||||
"""Remove surrogate pairs and malformed Unicode from text.
|
||||
|
||||
Surrogate pairs (U+D800-U+DFFF) are used in UTF-16 encoding but are
|
||||
invalid in UTF-8/Unicode strings. They can appear from:
|
||||
- Incorrect Unicode conversion
|
||||
- Corrupted text data
|
||||
- Invalid emoji/character sequences
|
||||
|
||||
Args:
|
||||
text: Input text potentially containing surrogates
|
||||
|
||||
Returns:
|
||||
Text with surrogates removed or replaced
|
||||
|
||||
Examples:
|
||||
>>> strip_surrogates("Hello \\uD83D World") # Missing low surrogate
|
||||
'Hello World'
|
||||
>>> strip_surrogates("Valid text 🌍")
|
||||
'Valid text 🌍'
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
try:
|
||||
# Method 1: Encode with surrogatepass, decode with ignore
|
||||
# This converts surrogates to UTF-16, then back to UTF-8 safely
|
||||
return text.encode('utf-16', 'surrogatepass').decode('utf-16', 'ignore')
|
||||
except (UnicodeError, LookupError):
|
||||
# Fallback: Remove surrogate code points directly
|
||||
return re.sub(r'[\uD800-\uDFFF]', '', text)
|
||||
|
||||
|
||||
def _sanitize_value_recursive(value: Any) -> tuple[Any, bool]:
|
||||
"""Recursively sanitize any nested string values in a data structure.
|
||||
|
||||
Args:
|
||||
value: Value to sanitize (can be dict, list, str, or other)
|
||||
|
||||
Returns:
|
||||
Tuple of (sanitized_value, changed_flag)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
sanitized = strip_surrogates(value)
|
||||
return sanitized, sanitized != value
|
||||
|
||||
elif isinstance(value, list):
|
||||
changed = False
|
||||
sanitized_items = []
|
||||
for item in value:
|
||||
sanitized_item, item_changed = _sanitize_value_recursive(item)
|
||||
sanitized_items.append(sanitized_item)
|
||||
changed = changed or item_changed
|
||||
return sanitized_items, changed
|
||||
|
||||
elif isinstance(value, dict):
|
||||
changed = False
|
||||
sanitized_dict = {}
|
||||
for key, nested_value in value.items():
|
||||
# Sanitize the key if it's a string
|
||||
if isinstance(key, str):
|
||||
sanitized_key = strip_surrogates(key)
|
||||
if sanitized_key != key:
|
||||
changed = True
|
||||
key = sanitized_key
|
||||
|
||||
# Recursively sanitize the value
|
||||
sanitized_value, value_changed = _sanitize_value_recursive(nested_value)
|
||||
sanitized_dict[key] = sanitized_value
|
||||
changed = changed or value_changed
|
||||
return sanitized_dict, changed
|
||||
|
||||
else:
|
||||
# Return other types unchanged
|
||||
return value, False
|
||||
|
||||
|
||||
def sanitize_messages(messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Sanitize message content in-place, removing problematic Unicode.
|
||||
|
||||
Processes all string content in message dictionaries, including:
|
||||
- message.content (string or list)
|
||||
- message.name
|
||||
- Any nested string values (including tool_calls[].function.arguments)
|
||||
|
||||
Args:
|
||||
messages: List of message dicts to sanitize in-place
|
||||
|
||||
Returns:
|
||||
True if any changes were made, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> messages = [{"content": "Hello \\uD83D World", "role": "user"}]
|
||||
>>> changed = sanitize_messages(messages)
|
||||
>>> assert changed == True
|
||||
>>> assert messages[0]["content"] == "Hello World"
|
||||
"""
|
||||
if not messages:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
# Recursively sanitize the entire message structure
|
||||
sanitized_message, message_changed = _sanitize_value_recursive(message)
|
||||
if message_changed:
|
||||
message.clear()
|
||||
message.update(sanitized_message)
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def sanitize_text(text: Union[str, None]) -> Union[str, None]:
|
||||
"""Sanitize a single text string.
|
||||
|
||||
Convenience function for sanitizing individual strings.
|
||||
|
||||
Args:
|
||||
text: Text to sanitize, or None
|
||||
|
||||
Returns:
|
||||
Sanitized text, or None if input was None
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
return strip_surrogates(text)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Streaming Protocol Extension - implements Gap 3 from Issue #1392.
|
||||
|
||||
This module completes the streaming adapter pattern by extending LLM adapters
|
||||
with comprehensive streaming capabilities, removing provider-specific conditionals
|
||||
from the core LLM loop.
|
||||
|
||||
Architecture:
|
||||
- Extends existing LLMProviderAdapterProtocol with streaming interface
|
||||
- Each adapter owns its streaming decisions and error recovery
|
||||
- Core llm.py contains no provider-name conditionals for streaming
|
||||
- StreamEvent protocol is used consistently for all streaming deltas
|
||||
- Observable fallback events instead of silent streaming->non-streaming fallback
|
||||
|
||||
Design principles:
|
||||
- Protocol-driven: complete the existing adapter pattern
|
||||
- DRY: eliminate scattered streaming conditionals
|
||||
- Extensibility: new providers = new adapter only (no core changes)
|
||||
- Observable: emit events for streaming capability decisions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List, AsyncIterator, Union, Protocol
|
||||
from abc import abstractmethod
|
||||
from ..streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamingCapableAdapter(Protocol):
|
||||
"""
|
||||
Extended protocol for LLM adapters with comprehensive streaming support.
|
||||
|
||||
This extends the base LLMProviderAdapterProtocol with streaming-specific
|
||||
methods that encapsulate all provider quirks and decisions.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""
|
||||
Determine if this provider can stream in the requested configuration.
|
||||
|
||||
Args:
|
||||
tools: Optional list of tools that will be used
|
||||
**kwargs: Additional LLM parameters that might affect streaming
|
||||
|
||||
Returns:
|
||||
True if streaming is possible with these parameters, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
async def stream_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream_emitter: Optional[StreamEventEmitter] = None,
|
||||
**kwargs
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Stream LLM completion using this provider's native streaming API.
|
||||
|
||||
Args:
|
||||
messages: Chat messages
|
||||
model: Model identifier
|
||||
temperature: Sampling temperature
|
||||
tools: Optional tools for function calling
|
||||
stream_emitter: Optional emitter for additional event handling
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Yields:
|
||||
StreamEvent instances with standardized streaming data
|
||||
|
||||
Raises:
|
||||
StreamingNotSupportedError: If streaming not available in this configuration
|
||||
StreamingError: If streaming fails and cannot recover
|
||||
"""
|
||||
...
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Determine if a streaming error can be recovered by falling back to non-streaming.
|
||||
|
||||
Args:
|
||||
exc: Exception that occurred during streaming
|
||||
|
||||
Returns:
|
||||
True if fallback to non-streaming should be attempted, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
|
||||
"""
|
||||
Create a StreamEvent indicating why streaming is not available.
|
||||
|
||||
This enables observable fallback instead of silent streaming -> non-streaming.
|
||||
|
||||
Args:
|
||||
reason: Human-readable reason why streaming is unavailable
|
||||
**metadata: Additional context for the unavailability
|
||||
|
||||
Returns:
|
||||
StreamEvent with type STREAM_UNAVAILABLE and reason
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class StreamingNotSupportedError(Exception):
|
||||
"""Raised when streaming is not supported in the current configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class StreamingError(Exception):
|
||||
"""Raised when streaming fails unrecoverably."""
|
||||
pass
|
||||
|
||||
|
||||
class DefaultStreamingAdapter:
|
||||
"""
|
||||
Default streaming adapter with sensible fallbacks.
|
||||
|
||||
Provides baseline streaming support for most providers that follow
|
||||
OpenAI-style streaming APIs.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Most providers support streaming, even with tools."""
|
||||
return True
|
||||
|
||||
async def stream_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream_emitter: Optional[StreamEventEmitter] = None,
|
||||
**kwargs
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Default streaming implementation using litellm.
|
||||
|
||||
This is the baseline implementation that most providers can use.
|
||||
Provider-specific adapters override this for custom behavior.
|
||||
"""
|
||||
import litellm
|
||||
import time
|
||||
|
||||
# Emit request start event
|
||||
start_time = time.perf_counter()
|
||||
request_start_event = StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=start_time,
|
||||
metadata={"model": model, "provider": "default"}
|
||||
)
|
||||
|
||||
# Yield the event to consumers and emit to optional stream_emitter
|
||||
yield request_start_event
|
||||
if stream_emitter:
|
||||
await stream_emitter.emit_async(request_start_event)
|
||||
|
||||
try:
|
||||
# Build completion parameters
|
||||
completion_params = {
|
||||
"messages": messages,
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
if tools:
|
||||
completion_params["tools"] = tools
|
||||
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
first_token_emitted = False
|
||||
|
||||
# Stream the completion
|
||||
async for chunk in litellm.acompletion(**completion_params):
|
||||
if chunk and chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
|
||||
# Handle text content
|
||||
if delta.content:
|
||||
if not first_token_emitted:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta.content
|
||||
)
|
||||
first_token_emitted = True
|
||||
else:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta.content
|
||||
)
|
||||
response_text += delta.content
|
||||
|
||||
# Handle tool calls
|
||||
if delta.tool_calls:
|
||||
for tool_call in delta.tool_calls:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"id": tool_call.id,
|
||||
"name": tool_call.function.name if tool_call.function else None,
|
||||
"arguments": tool_call.function.arguments if tool_call.function else None
|
||||
}
|
||||
)
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
# Emit stream completion
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time.perf_counter(),
|
||||
metadata={
|
||||
"response_text": response_text,
|
||||
"tool_calls": [tc.to_dict() if hasattr(tc, 'to_dict') else tc for tc in tool_calls]
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Emit error event
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.ERROR,
|
||||
timestamp=time.perf_counter(),
|
||||
error=str(e),
|
||||
metadata={"exception_type": type(e).__name__}
|
||||
)
|
||||
|
||||
if not self.is_stream_error_recoverable(e):
|
||||
raise StreamingError(f"Unrecoverable streaming error: {e}") from e
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Default error recovery logic.
|
||||
|
||||
Most JSON parsing errors and timeout errors are recoverable.
|
||||
"""
|
||||
error_str = str(exc).lower()
|
||||
recoverable_patterns = [
|
||||
"json",
|
||||
"parsing",
|
||||
"timeout",
|
||||
"connection"
|
||||
]
|
||||
return any(pattern in error_str for pattern in recoverable_patterns)
|
||||
|
||||
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
|
||||
"""Create standard stream unavailable event."""
|
||||
import time
|
||||
return StreamEvent(
|
||||
type=StreamEventType.STREAM_UNAVAILABLE,
|
||||
timestamp=time.perf_counter(),
|
||||
error=f"Streaming unavailable: {reason}",
|
||||
metadata={
|
||||
"reason": reason,
|
||||
**metadata
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OllamaStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Ollama-specific streaming adapter.
|
||||
|
||||
Handles Ollama's streaming limitations:
|
||||
- Doesn't support streaming with tools reliably
|
||||
- Has specific error patterns that need different recovery
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Ollama can stream, but not reliably with tools."""
|
||||
if tools:
|
||||
return False # Disable streaming when tools are present
|
||||
return True
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""Ollama-specific error recovery patterns."""
|
||||
error_str = str(exc).lower()
|
||||
ollama_recoverable = [
|
||||
"json",
|
||||
"parsing",
|
||||
"connection reset",
|
||||
"incomplete",
|
||||
"tool" # Tool-related errors are often recoverable by disabling tools
|
||||
]
|
||||
return any(pattern in error_str for pattern in ollama_recoverable)
|
||||
|
||||
|
||||
class AnthropicStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Anthropic/Claude streaming adapter.
|
||||
|
||||
Handles the known litellm/async-generator bug with Anthropic streaming.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""
|
||||
Anthropic streaming is disabled due to litellm bug.
|
||||
|
||||
Issue: litellm.acompletion with stream=True returns a ModelResponse (not async generator)
|
||||
for Anthropic in the async path, causing 'async for requires __aiter__' error.
|
||||
"""
|
||||
return False # Disable until litellm bug is fixed
|
||||
|
||||
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
|
||||
"""Create Anthropic-specific unavailable event with bug details."""
|
||||
return super().create_stream_unavailable_event(
|
||||
reason or "litellm async generator bug",
|
||||
provider="anthropic",
|
||||
# TODO: Add actual bug reference when litellm issue is filed
|
||||
**metadata
|
||||
)
|
||||
|
||||
|
||||
class GeminiStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Google Gemini streaming adapter.
|
||||
|
||||
Handles Gemini's streaming issues with tools and JSON parsing.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Gemini has issues with streaming + tools."""
|
||||
if tools:
|
||||
return False # Disable streaming when tools are present due to JSON parsing issues
|
||||
return True
|
||||
|
||||
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
|
||||
"""Create Gemini-specific unavailable event."""
|
||||
return super().create_stream_unavailable_event(
|
||||
reason or "JSON parsing issues with tools",
|
||||
provider="gemini",
|
||||
**metadata
|
||||
)
|
||||
|
||||
|
||||
# Streaming adapter registry
|
||||
_streaming_adapters: Dict[str, StreamingCapableAdapter] = {}
|
||||
|
||||
# Register core streaming adapters
|
||||
_streaming_adapters['default'] = DefaultStreamingAdapter()
|
||||
_streaming_adapters['ollama'] = OllamaStreamingAdapter()
|
||||
_streaming_adapters['anthropic'] = AnthropicStreamingAdapter()
|
||||
_streaming_adapters['claude'] = AnthropicStreamingAdapter() # Alias
|
||||
_streaming_adapters['gemini'] = GeminiStreamingAdapter()
|
||||
|
||||
|
||||
def get_streaming_adapter(provider_name: str) -> StreamingCapableAdapter:
|
||||
"""
|
||||
Get streaming adapter for provider with fallback to default.
|
||||
|
||||
Args:
|
||||
provider_name: Provider name (e.g., "anthropic", "ollama", "gemini")
|
||||
|
||||
Returns:
|
||||
StreamingCapableAdapter instance
|
||||
"""
|
||||
name_lower = provider_name.lower()
|
||||
|
||||
# Exact match first
|
||||
if name_lower in _streaming_adapters:
|
||||
return _streaming_adapters[name_lower]
|
||||
|
||||
# Provider prefixes or substrings
|
||||
if "ollama" in name_lower:
|
||||
return _streaming_adapters["ollama"]
|
||||
if "claude" in name_lower or "anthropic" in name_lower:
|
||||
return _streaming_adapters["anthropic"]
|
||||
if "gemini" in name_lower:
|
||||
return _streaming_adapters["gemini"]
|
||||
|
||||
return _streaming_adapters["default"]
|
||||
|
||||
|
||||
def add_streaming_adapter(name: str, adapter: StreamingCapableAdapter) -> None:
|
||||
"""Register a custom streaming adapter."""
|
||||
_streaming_adapters[name] = adapter
|
||||
|
||||
|
||||
def list_streaming_adapters() -> List[str]:
|
||||
"""List all registered streaming adapter names."""
|
||||
return sorted(_streaming_adapters.keys())
|
||||
@@ -237,7 +237,14 @@ class OpenAIAdapter:
|
||||
reasoning_steps=kwargs.get('reasoning_steps', False),
|
||||
verbose=kwargs.get('verbose', True),
|
||||
max_iterations=kwargs.get('max_iterations', 10),
|
||||
**{k: v for k, v in kwargs.items() if k not in ['max_tokens', 'execute_tool_fn', 'console', 'display_fn', 'reasoning_steps', 'verbose', 'max_iterations']}
|
||||
**{k: v for k, v in kwargs.items() if k not in {
|
||||
'max_tokens', 'execute_tool_fn', 'console', 'display_fn',
|
||||
'reasoning_steps', 'verbose', 'max_iterations',
|
||||
# Application metadata that must never reach OpenAI API
|
||||
'markdown', 'agent_name', 'agent_role', 'agent_tools',
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'stream_callback', 'emit_events'
|
||||
}}
|
||||
)
|
||||
|
||||
def chat_completion(
|
||||
|
||||
@@ -676,6 +676,8 @@ class TaskOutput(BaseModel):
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None # Add token metrics field
|
||||
callback_error: Optional[str] = None
|
||||
non_fatal_errors: Optional[list[str]] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
@@ -696,4 +698,4 @@ class TaskOutput(BaseModel):
|
||||
elif self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
else:
|
||||
return self.raw
|
||||
return self.raw
|
||||
@@ -25,6 +25,7 @@ from .protocols import (
|
||||
ComputeConfig,
|
||||
InstanceInfo,
|
||||
InstanceStatus,
|
||||
ManagedRuntimeProtocol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -41,4 +42,14 @@ __all__ = [
|
||||
"ComputeConfig",
|
||||
"InstanceInfo",
|
||||
"InstanceStatus",
|
||||
"ManagedRuntimeProtocol",
|
||||
"ManagedBackendProtocol",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import ManagedBackendProtocol to keep module lightweight."""
|
||||
if name == "ManagedBackendProtocol":
|
||||
from ..agent.protocols import ManagedBackendProtocol
|
||||
return ManagedBackendProtocol
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -23,6 +23,7 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
@@ -216,3 +217,140 @@ class ComputeProviderProtocol(Protocol):
|
||||
List of InstanceInfo for running instances.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManagedRuntimeProtocol(Protocol):
|
||||
"""Protocol for hosted agent runtime — the agent LOOP runs on remote infra.
|
||||
|
||||
Separates from ComputeProviderProtocol which only sandboxes tool execution.
|
||||
Here, the entire agent loop (LLM calls, tool calling, event streaming,
|
||||
memory, session management) runs remotely.
|
||||
|
||||
This is the protocol that Anthropic Managed Agents, E2B Managed Runtime,
|
||||
Modal Managed Functions, etc. should implement.
|
||||
|
||||
Example lifecycle::
|
||||
|
||||
runtime = AnthropicManagedAgent(config=cfg)
|
||||
agent_id = await runtime.create_agent(config)
|
||||
env_id = await runtime.create_environment(config)
|
||||
session_id = await runtime.create_session(agent_id, env_id)
|
||||
|
||||
# Send user message and stream agent responses
|
||||
await runtime.send_event(session_id, {"type": "user.message", ...})
|
||||
async for event in runtime.stream_events(session_id):
|
||||
if event["type"] == "agent.message":
|
||||
print(event["content"])
|
||||
"""
|
||||
|
||||
async def create_agent(self, config: Dict[str, Any]) -> str:
|
||||
"""Create agent definition on remote infrastructure.
|
||||
|
||||
Args:
|
||||
config: Agent configuration (model, system, tools, etc.)
|
||||
|
||||
Returns:
|
||||
Agent ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_environment(self, config: Dict[str, Any]) -> str:
|
||||
"""Create environment (container/sandbox) on remote infrastructure.
|
||||
|
||||
Args:
|
||||
config: Environment configuration (packages, networking, etc.)
|
||||
|
||||
Returns:
|
||||
Environment ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
agent_id: str,
|
||||
environment_id: str,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Create running session (agent-in-environment) on remote infrastructure.
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID from create_agent()
|
||||
environment_id: Environment ID from create_environment()
|
||||
**kwargs: Additional session config (vault_ids, resources, etc.)
|
||||
|
||||
Returns:
|
||||
Session ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def send_event(self, session_id: str, event: Dict[str, Any]) -> None:
|
||||
"""Send event to running session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID from create_session()
|
||||
event: Event dict (e.g. {"type": "user.message", "content": [...]})
|
||||
"""
|
||||
...
|
||||
|
||||
def stream_events(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Stream events from running session.
|
||||
|
||||
Implementations should be async generators: ``async def`` with ``yield``.
|
||||
Declared without ``async def`` here so the protocol signature matches the
|
||||
async-generator return type (``AsyncIterator``) rather than a coroutine
|
||||
that resolves to one, letting callers write ``async for e in runtime.stream_events(sid)``.
|
||||
|
||||
Args:
|
||||
session_id: Session ID from create_session()
|
||||
|
||||
Yields:
|
||||
Event dicts (agent.message, agent.tool_use, session.status_idle, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
async def interrupt(self, session_id: str) -> None:
|
||||
"""Send interrupt signal to running session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to interrupt
|
||||
"""
|
||||
...
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Retrieve session metadata and status.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to retrieve
|
||||
|
||||
Returns:
|
||||
Session info dict with id, status, title, usage, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_sessions(self, **filters) -> List[Dict[str, Any]]:
|
||||
"""List sessions with optional filtering.
|
||||
|
||||
Args:
|
||||
**filters: Filter criteria (agent_id, status, limit, etc.)
|
||||
|
||||
Returns:
|
||||
List of session info dicts
|
||||
"""
|
||||
...
|
||||
|
||||
async def archive_session(self, session_id: str) -> None:
|
||||
"""Archive session (preserve but mark inactive).
|
||||
|
||||
Args:
|
||||
session_id: Session ID to archive
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_session(self, session_id: str) -> None:
|
||||
"""Delete session permanently.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to delete
|
||||
"""
|
||||
...
|
||||
@@ -34,10 +34,8 @@ Memory Providers:
|
||||
- MCPConfigManager: MCP server configuration management
|
||||
"""
|
||||
|
||||
from .file_memory import FileMemory, create_memory
|
||||
from .rules_manager import RulesManager, Rule, create_rules_manager
|
||||
from .docs_manager import DocsManager, Doc
|
||||
from .mcp_config import MCPConfigManager, MCPConfig
|
||||
# Import only essential protocols to avoid loading heavy implementations at import time
|
||||
# Following AGENTS.md principle: "No module-level imports of optional dependencies"
|
||||
from .protocols import (
|
||||
MemoryProtocol,
|
||||
AsyncMemoryProtocol,
|
||||
@@ -51,6 +49,36 @@ from .protocols import (
|
||||
|
||||
# Lazy imports for optional modules to avoid dependency issues and improve startup time
|
||||
def __getattr__(name):
|
||||
# Core memory implementations - lazy loaded to avoid startup overhead
|
||||
if name == "FileMemory":
|
||||
from .file_memory import FileMemory
|
||||
return FileMemory
|
||||
if name == "create_memory":
|
||||
from .file_memory import create_memory
|
||||
return create_memory
|
||||
if name == "RulesManager":
|
||||
from .rules_manager import RulesManager
|
||||
return RulesManager
|
||||
if name == "Rule":
|
||||
from .rules_manager import Rule
|
||||
return Rule
|
||||
if name == "create_rules_manager":
|
||||
from .rules_manager import create_rules_manager
|
||||
return create_rules_manager
|
||||
if name == "DocsManager":
|
||||
from .docs_manager import DocsManager
|
||||
return DocsManager
|
||||
if name == "Doc":
|
||||
from .docs_manager import Doc
|
||||
return Doc
|
||||
if name == "MCPConfigManager":
|
||||
from .mcp_config import MCPConfigManager
|
||||
return MCPConfigManager
|
||||
if name == "MCPConfig":
|
||||
from .mcp_config import MCPConfig
|
||||
return MCPConfig
|
||||
|
||||
# Optional heavy memory implementations
|
||||
if name == "Memory":
|
||||
from .memory import Memory
|
||||
return Memory
|
||||
@@ -160,7 +188,6 @@ def __getattr__(name):
|
||||
if name == "ImprovementStore":
|
||||
from .learn import ImprovementStore
|
||||
return ImprovementStore
|
||||
# DocsManager and MCPConfigManager are already imported at module level
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -219,4 +246,4 @@ __all__ = [
|
||||
"DeletableMemoryProtocol",
|
||||
"AsyncDeletableMemoryProtocol",
|
||||
"EntityMemoryProtocol",
|
||||
]
|
||||
]
|
||||
@@ -304,6 +304,12 @@ class ChromaMemoryAdapter:
|
||||
else:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
|
||||
def close(self):
|
||||
"""Clean up ChromaDB resources."""
|
||||
# ChromaDB PersistentClient has no explicit close; release references.
|
||||
self.client = None
|
||||
self.collection = None
|
||||
|
||||
|
||||
class MongoDBMemoryAdapter:
|
||||
@@ -492,4 +498,15 @@ class MongoDBMemoryAdapter:
|
||||
result = embedding(text, model="text-embedding-3-small")
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Clean up MongoDB resources."""
|
||||
if hasattr(self, 'client') and self.client:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"MongoDB cleanup failed: {e}")
|
||||
finally:
|
||||
self.client = None
|
||||
@@ -23,17 +23,27 @@ class InMemoryAdapter:
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
self._max_size: int = kwargs.get("max_size", 10_000) # Default limit
|
||||
self._next_id: int = 0 # Monotonic counter to prevent ID collisions after eviction
|
||||
|
||||
def _evict_if_needed(self):
|
||||
"""Evict old entries if we exceed max size (FIFO eviction)."""
|
||||
if len(self._data) > self._max_size:
|
||||
# Keep only the most recent entries
|
||||
self._data = self._data[len(self._data) - self._max_size:]
|
||||
|
||||
def store_short_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_short_term(
|
||||
@@ -50,12 +60,14 @@ class InMemoryAdapter:
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_long_term(
|
||||
|
||||
@@ -6,6 +6,7 @@ in memory/memory.py. This enables protocol-driven memory backends without
|
||||
core modifications.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Callable, List, Optional, Tuple, Type
|
||||
from ...utils.adapter_registry import AdapterRegistry
|
||||
from ..protocols import MemoryProtocol
|
||||
@@ -33,28 +34,31 @@ class MemoryAdapterRegistry(AdapterRegistry[MemoryProtocol]):
|
||||
super().__init__(adapter_type_name="Memory")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_memory_registry = MemoryAdapterRegistry()
|
||||
# Default registry instance for backward compatibility
|
||||
@lru_cache(maxsize=1)
|
||||
def get_default_memory_registry() -> MemoryAdapterRegistry:
|
||||
"""Get the default global registry instance for convenience."""
|
||||
return MemoryAdapterRegistry()
|
||||
|
||||
|
||||
def register_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter class."""
|
||||
_memory_registry.register_adapter(name, adapter_class)
|
||||
get_default_memory_registry().register_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def register_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter factory function."""
|
||||
_memory_registry.register_factory(name, factory_func)
|
||||
get_default_memory_registry().register_factory(name, factory_func)
|
||||
|
||||
|
||||
def get_memory_adapter(name: str, **kwargs) -> Optional[MemoryProtocol]:
|
||||
"""Get memory adapter instance by name."""
|
||||
return _memory_registry.get_adapter(name, **kwargs)
|
||||
return get_default_memory_registry().get_adapter(name, **kwargs)
|
||||
|
||||
|
||||
def list_memory_adapters() -> List[str]:
|
||||
"""List all registered memory adapter names."""
|
||||
return _memory_registry.list_adapters()
|
||||
return get_default_memory_registry().list_adapters()
|
||||
|
||||
|
||||
# Canonical aliases per AGENTS.md naming conventions
|
||||
@@ -70,7 +74,7 @@ def add_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -
|
||||
|
||||
def has_memory_adapter(name: str) -> bool:
|
||||
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
|
||||
return _memory_registry.is_available(name)
|
||||
return get_default_memory_registry().is_available(name)
|
||||
|
||||
|
||||
def get_first_available_memory_adapter(
|
||||
@@ -89,7 +93,7 @@ def get_first_available_memory_adapter(
|
||||
if preferences is None:
|
||||
preferences = ["sqlite", "in_memory"]
|
||||
|
||||
return _memory_registry.get_first_available(preferences, **kwargs)
|
||||
return get_default_memory_registry().get_first_available(preferences, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -62,16 +62,14 @@ class MemoryCoreMixin:
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Failed to store in {self.provider} STM: {e}", logging.WARNING)
|
||||
|
||||
# Backward compatibility: Also store in SQLite if not using SQLite adapter
|
||||
if hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
# Only use SQLite fallback if primary storage failed completely
|
||||
if not memory_id and hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
fallback_id = self._sqlite_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
if not memory_id:
|
||||
memory_id = fallback_id
|
||||
memory_id = self._sqlite_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in SQLite STM as fallback: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM fallback: {e}")
|
||||
if not memory_id:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
# Auto-promote to long-term memory if quality is high
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
@@ -122,38 +120,39 @@ class MemoryCoreMixin:
|
||||
clean_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
# Protocol-driven storage: Try primary adapter first
|
||||
memory_id = ""
|
||||
primary_error = None
|
||||
memory_id = None
|
||||
|
||||
try:
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
memory_id = self.memory_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in {self.provider} STM via adapter: {content[:100]}...")
|
||||
|
||||
# Auto-promote to long-term memory if quality is high
|
||||
if auto_promote and quality_score >= 7.5:
|
||||
try:
|
||||
self.store_long_term(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
# Auto-promotion failure doesn't affect the primary storage result
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
|
||||
# Emit memory event for successful storage
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
return MemoryResult.success_result(
|
||||
memory_id=memory_id,
|
||||
adapter_used=self.provider,
|
||||
context={
|
||||
"quality_score": quality_score,
|
||||
"auto_promoted": auto_promote and quality_score >= 7.5
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
primary_error = str(e)
|
||||
self._log_verbose(f"Failed to store in {self.provider} STM: {e}", logging.WARNING)
|
||||
|
||||
# Only proceed with success if we got a valid memory_id
|
||||
if memory_id:
|
||||
# Auto-promote to long-term memory if quality is high
|
||||
if auto_promote and quality_score >= 7.5:
|
||||
try:
|
||||
self.store_long_term(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
# Auto-promotion failure doesn't affect the primary storage result
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
|
||||
# Emit memory event for successful storage
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
return MemoryResult.success_result(
|
||||
memory_id=memory_id,
|
||||
adapter_used=self.provider,
|
||||
context={
|
||||
"quality_score": quality_score,
|
||||
"auto_promoted": auto_promote and quality_score >= 7.5
|
||||
}
|
||||
)
|
||||
|
||||
# Fallback to SQLite if available and different from primary adapter
|
||||
fallback_error = None
|
||||
if hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
@@ -417,7 +416,7 @@ class MemoryCoreMixin:
|
||||
|
||||
# Async variants to prevent event loop blocking
|
||||
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
|
||||
user_id: Optional[str] = None, auto_promote: bool = True, **kwargs) -> str:
|
||||
"""
|
||||
Async version of store_short_term to prevent event loop blocking.
|
||||
|
||||
@@ -427,6 +426,7 @@ class MemoryCoreMixin:
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to automatically promote to LTM if quality is high
|
||||
**kwargs: Additional provider-specific arguments forwarded to the adapter
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -443,25 +443,36 @@ class MemoryCoreMixin:
|
||||
raw_metadata = metadata.copy() if metadata else {}
|
||||
raw_metadata.update({
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"quality_score": quality_score,
|
||||
"memory_type": "short_term"
|
||||
"quality": quality_score,
|
||||
})
|
||||
if user_id:
|
||||
raw_metadata["user_id"] = user_id
|
||||
clean_metadata = self._sanitize_metadata(raw_metadata)
|
||||
|
||||
# Store in SQLite STM
|
||||
# Try primary adapter first (async version)
|
||||
memory_id = ""
|
||||
try:
|
||||
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
memory_id = await asyncio.to_thread(
|
||||
self.memory_adapter.store_short_term, content, metadata=clean_metadata, **kwargs
|
||||
)
|
||||
self._log_verbose(f"Stored in {self.provider} async STM via adapter: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM: {e}")
|
||||
return ""
|
||||
self._log_verbose(f"Failed to store in {self.provider} async STM: {e}", logging.WARNING)
|
||||
|
||||
# Only use SQLite fallback if primary storage failed completely
|
||||
if not memory_id and hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
memory_id = await asyncio.to_thread(self._sqlite_adapter.store_short_term, content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in SQLite async STM as fallback: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite async STM fallback: {e}")
|
||||
return ""
|
||||
|
||||
# Auto-promote to long-term memory if quality is high (async)
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
try:
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
@@ -474,7 +485,7 @@ class MemoryCoreMixin:
|
||||
return memory_id or ""
|
||||
|
||||
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None) -> str:
|
||||
user_id: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Async version of store_long_term to prevent event loop blocking.
|
||||
|
||||
@@ -483,6 +494,7 @@ class MemoryCoreMixin:
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
**kwargs: Additional provider-specific arguments forwarded to the adapter
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -495,4 +507,4 @@ class MemoryCoreMixin:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Use sync version in thread to avoid blocking event loop
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id, **kwargs)
|
||||
@@ -485,7 +485,7 @@ class LearnManager:
|
||||
}
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
@@ -503,12 +503,14 @@ Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
4. IMPROVEMENTS: Concrete proposals to improve future responses
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
"patterns": ["pattern 1", "pattern 2"],
|
||||
"improvements": ["improvement 1", "improvement 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
@@ -532,10 +534,10 @@ Return empty arrays if nothing is found for a category."""
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
extracted = {"persona": [], "insights": [], "patterns": [], "improvements": []}
|
||||
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0, "improvements": 0}
|
||||
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
@@ -552,18 +554,24 @@ Return empty arrays if nothing is found for a category."""
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
for improvement in extracted.get("improvements", []):
|
||||
if improvement and "improvements" in self._stores:
|
||||
self.capture_improvement(improvement, source="auto_extraction")
|
||||
stored["improvements"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"improvements": extracted.get("improvements", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}, "error": str(e)}
|
||||
|
||||
async def aprocess_conversation(
|
||||
self,
|
||||
@@ -663,4 +671,4 @@ Return empty arrays if nothing is found for a category."""
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}, "error": str(e)}
|
||||
@@ -460,6 +460,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
def _init_mongodb(self):
|
||||
"""Initialize MongoDB client for memory storage."""
|
||||
try:
|
||||
# Import MongoClient locally to handle optional dependency
|
||||
from pymongo import MongoClient
|
||||
|
||||
mongo_cfg = self.cfg.get("config", {})
|
||||
self.connection_string = mongo_cfg.get("connection_string", "mongodb://localhost:27017/")
|
||||
self.database_name = mongo_cfg.get("database", "praisonai")
|
||||
@@ -635,36 +638,49 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
ident = str(time.time_ns())
|
||||
created_at = time.time()
|
||||
|
||||
# Store in MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_short_term"):
|
||||
# Protocol-driven storage: Try adapter first if available
|
||||
adapter_success = False
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
try:
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
logger.info(f"Successfully stored in MongoDB short-term memory with ID: {ident}")
|
||||
result_id = self.memory_adapter.store_short_term(text, metadata=metadata)
|
||||
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
|
||||
adapter_success = True
|
||||
ident = result_id # Use adapter-provided ID
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
logger.warning(f"Failed to store via memory adapter, falling back to direct storage: {e}")
|
||||
|
||||
# Only use direct storage if adapter failed or doesn't exist
|
||||
if not adapter_success:
|
||||
# Store in MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_short_term"):
|
||||
try:
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
logger.info(f"Successfully stored in MongoDB short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
if not self.use_mongodb: # Only raise if we're not using MongoDB as fallback
|
||||
raise
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
if not self.use_mongodb: # Only raise if we're not using MongoDB as fallback
|
||||
raise
|
||||
|
||||
# Emit trace event for memory store
|
||||
self._emit_memory_event("store", "short_term", len(text), metadata=metadata)
|
||||
@@ -965,9 +981,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
# Filter by quality
|
||||
filtered = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
logger.info(f"Found {len(filtered)} results in Mem0")
|
||||
return filtered
|
||||
found.extend(filtered)
|
||||
|
||||
elif self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
if not found and self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
try:
|
||||
results = []
|
||||
|
||||
@@ -1031,13 +1047,13 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(results)} results in MongoDB")
|
||||
return results
|
||||
found.extend(results)
|
||||
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching MongoDB long-term memory: {e}", logging.ERROR)
|
||||
# Fall through to SQLite search
|
||||
|
||||
elif self.use_rag and hasattr(self, "chroma_col"):
|
||||
if not found and self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
result = get_embedding(query, model=self.embedding_model)
|
||||
@@ -1045,29 +1061,28 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
if query_embedding is None:
|
||||
self._log_verbose("Failed to get embedding for query", logging.WARNING)
|
||||
return []
|
||||
|
||||
# Search ChromaDB with embedding
|
||||
resp = self.chroma_col.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
results = []
|
||||
if resp["ids"]:
|
||||
for i in range(len(resp["ids"][0])):
|
||||
metadata = resp["metadatas"][0][i] if "metadatas" in resp else {}
|
||||
text = resp["documents"][0][i]
|
||||
# Add memory record citation
|
||||
text = f"{text} (Memory record: {resp['ids'][0][i]})"
|
||||
found.append({
|
||||
"id": resp["ids"][0][i],
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"score": 1.0 - (resp["distances"][0][i] if "distances" in resp else 0.0)
|
||||
})
|
||||
logger.info(f"Found {len(found)} results in ChromaDB")
|
||||
else:
|
||||
# Search ChromaDB with embedding
|
||||
resp = self.chroma_col.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
results = []
|
||||
if resp["ids"]:
|
||||
for i in range(len(resp["ids"][0])):
|
||||
metadata = resp["metadatas"][0][i] if "metadatas" in resp else {}
|
||||
text = resp["documents"][0][i]
|
||||
# Add memory record citation
|
||||
text = f"{text} (Memory record: {resp['ids'][0][i]})"
|
||||
found.append({
|
||||
"id": resp["ids"][0][i],
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"score": 1.0 - (resp["distances"][0][i] if "distances" in resp else 0.0)
|
||||
})
|
||||
logger.info(f"Found {len(found)} results in ChromaDB")
|
||||
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
@@ -1085,7 +1100,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
text = row[1]
|
||||
# Add memory record citation if not already present
|
||||
if "(Memory record:" not in text:
|
||||
text = f"{text} (Memory record: {text})"
|
||||
text = f"{text} (Memory record: {row[0]})"
|
||||
# Only add if not already found by ChromaDB/Mem0
|
||||
if not any(f["id"] == row[0] for f in found):
|
||||
found.append({
|
||||
@@ -1945,14 +1960,19 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
def close_connections(self):
|
||||
"""
|
||||
Close database connections.
|
||||
|
||||
Closes the current thread's connections and attempts to close all known
|
||||
connections from other threads. Each thread should call this method before
|
||||
terminating to ensure proper cleanup.
|
||||
|
||||
Closes only the calling thread's STM/LTM SQLite connections (and the
|
||||
process-wide MongoDB client, if any). Connections owned by other threads
|
||||
are left untouched so concurrent agents are not interfered with — each
|
||||
thread is responsible for calling this before terminating. For full
|
||||
process shutdown, use the adapter's own bulk-close path.
|
||||
"""
|
||||
# Close current thread's connections
|
||||
if hasattr(self._local, 'stm_conn') and self._local.stm_conn:
|
||||
try:
|
||||
# Remove from registry before closing
|
||||
with self._connection_lock:
|
||||
self._all_connections.discard(self._local.stm_conn)
|
||||
self._local.stm_conn.close()
|
||||
self._local.stm_conn = None
|
||||
except Exception as e:
|
||||
@@ -1960,21 +1980,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
if hasattr(self._local, 'ltm_conn') and self._local.ltm_conn:
|
||||
try:
|
||||
# Remove from registry before closing
|
||||
with self._connection_lock:
|
||||
self._all_connections.discard(self._local.ltm_conn)
|
||||
self._local.ltm_conn.close()
|
||||
self._local.ltm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's LTM connection: {e}")
|
||||
|
||||
# Close all known connections from the registry
|
||||
with self._connection_lock: # Ensure thread safety during cleanup
|
||||
connections_to_close = list(self._all_connections)
|
||||
for conn in connections_to_close:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing registered connection: {e}")
|
||||
# Clear the registry
|
||||
self._all_connections.clear()
|
||||
# NOTE: Only close THIS thread's connections, not ALL connections
|
||||
# Other active threads should manage their own connections
|
||||
# Only clear full registry in __del__ or explicit shutdown
|
||||
|
||||
# Close memory adapter if it exists (protocol-driven path)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
try:
|
||||
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
|
||||
self.memory_adapter.close()
|
||||
logger.debug("Memory adapter closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing memory adapter: {e}")
|
||||
finally:
|
||||
self.memory_adapter = None
|
||||
|
||||
# Close MongoDB client if it exists (legacy path)
|
||||
if hasattr(self, 'mongo_client') and self.mongo_client:
|
||||
try:
|
||||
self.mongo_client.close()
|
||||
self.mongo_client = None
|
||||
logger.debug("MongoDB client closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing MongoDB client: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow Memory to be used as a context manager."""
|
||||
|
||||
@@ -95,13 +95,23 @@ class StorageMixin:
|
||||
# Initialize collections
|
||||
try:
|
||||
self.stm_collection = self.chroma_client.get_collection("short_term_memory")
|
||||
except:
|
||||
except ValueError: # ChromaDB raises ValueError for missing collections
|
||||
self.stm_collection = self.chroma_client.create_collection("short_term_memory")
|
||||
except Exception as e:
|
||||
from .._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to initialize short_term_memory collection: {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
self.ltm_collection = self.chroma_client.get_collection("long_term_memory")
|
||||
except:
|
||||
except ValueError: # ChromaDB raises ValueError for missing collections
|
||||
self.ltm_collection = self.chroma_client.create_collection("long_term_memory")
|
||||
except Exception as e:
|
||||
from .._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to initialize long_term_memory collection: {e}")
|
||||
raise
|
||||
|
||||
def _init_mongodb(self):
|
||||
"""Initialize MongoDB for storage."""
|
||||
|
||||
@@ -24,6 +24,8 @@ class TaskOutput(BaseModel):
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None
|
||||
callback_error: Optional[str] = None
|
||||
non_fatal_errors: Optional[list[str]] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
|
||||
@@ -78,6 +78,9 @@ __all__ = [
|
||||
# ============================================================================
|
||||
|
||||
# Global state for plugin system (lazy initialized)
|
||||
import threading
|
||||
|
||||
_plugins_lock = threading.Lock()
|
||||
_plugins_enabled: bool = False
|
||||
_enabled_plugin_names: list = None # None = all, list = specific
|
||||
|
||||
@@ -108,8 +111,9 @@ def enable(plugins: list = None) -> None:
|
||||
"""
|
||||
global _plugins_enabled, _enabled_plugin_names
|
||||
|
||||
_plugins_enabled = True
|
||||
_enabled_plugin_names = plugins # None = all, list = specific
|
||||
with _plugins_lock:
|
||||
_plugins_enabled = True
|
||||
_enabled_plugin_names = plugins # None = all, list = specific
|
||||
|
||||
# Get plugin manager and auto-discover
|
||||
from .manager import get_plugin_manager
|
||||
@@ -119,10 +123,14 @@ def enable(plugins: list = None) -> None:
|
||||
manager.auto_discover_plugins()
|
||||
manager.discover_entry_points()
|
||||
|
||||
# Snapshot the names under lock to avoid TOCTOU
|
||||
with _plugins_lock:
|
||||
target_plugins = list(_enabled_plugin_names) if _enabled_plugin_names is not None else None
|
||||
|
||||
# Enable specific plugins or all
|
||||
if plugins is not None:
|
||||
if target_plugins is not None:
|
||||
# Enable only specified plugins
|
||||
for name in plugins:
|
||||
for name in target_plugins:
|
||||
manager.enable(name)
|
||||
else:
|
||||
# Enable all discovered plugins
|
||||
@@ -158,8 +166,9 @@ def disable(plugins: list = None) -> None:
|
||||
manager.disable(name)
|
||||
else:
|
||||
# Disable all plugins
|
||||
_plugins_enabled = False
|
||||
_enabled_plugin_names = None
|
||||
with _plugins_lock:
|
||||
_plugins_enabled = False
|
||||
_enabled_plugin_names = None
|
||||
for plugin_info in manager.list_plugins():
|
||||
manager.disable(plugin_info.get("name", ""))
|
||||
|
||||
@@ -225,10 +234,9 @@ def is_enabled(name: str = None) -> bool:
|
||||
Returns:
|
||||
True if enabled, False otherwise.
|
||||
"""
|
||||
global _plugins_enabled
|
||||
|
||||
if name is None:
|
||||
return _plugins_enabled
|
||||
with _plugins_lock:
|
||||
if name is None:
|
||||
return _plugins_enabled
|
||||
|
||||
from .manager import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
|
||||
@@ -45,6 +45,8 @@ class Process:
|
||||
self.workflow_timeout = workflow_timeout
|
||||
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
|
||||
self.workflow_finished = False # ADDED: Workflow finished flag
|
||||
self.workflow_cancelled = False # ADDED: Workflow cancellation flag for timeout
|
||||
self._state_lock_init = threading.Lock() # Thread lock for synchronous shared-state updates
|
||||
self._state_lock = None # Lazy-initialized async lock for shared state protection
|
||||
|
||||
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
|
||||
@@ -62,6 +64,14 @@ class Process:
|
||||
|
||||
logging.debug(f"Verbose mode: {self._verbose}")
|
||||
|
||||
async def _get_state_lock(self):
|
||||
"""Get or create the async state lock (must be called from async context)."""
|
||||
if self._state_lock is None:
|
||||
with self._state_lock_init: # Use existing threading.Lock for synchronization
|
||||
if self._state_lock is None: # Double-check after acquiring
|
||||
self._state_lock = asyncio.Lock()
|
||||
return self._state_lock
|
||||
|
||||
def _create_llm_instance(self):
|
||||
"""Create and return a configured LLM instance for manager tasks."""
|
||||
return LLM(model=self.manager_llm, temperature=0.7)
|
||||
@@ -171,6 +181,14 @@ class Process:
|
||||
logging.error(f"Failed to create subtasks for loop task {loop_task.name}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Mark task as failed to ensure error is visible
|
||||
loop_task.status = "failed"
|
||||
if hasattr(loop_task, 'result') and loop_task.result is None:
|
||||
from ..output.models import TaskOutput
|
||||
loop_task.result = TaskOutput(
|
||||
raw=f"Failed to read input file: {e}",
|
||||
task_id=loop_task.id
|
||||
)
|
||||
|
||||
def _build_task_context(self, current_task: Task) -> str:
|
||||
"""Build context for a task based on its retain_full_context setting"""
|
||||
@@ -228,6 +246,12 @@ class Process:
|
||||
break # Only include the most recent one
|
||||
|
||||
return context
|
||||
|
||||
async def _set_workflow_finished(self, value: bool):
|
||||
"""Thread-safe setter for workflow_finished flag."""
|
||||
lock = await self._get_state_lock()
|
||||
async with lock:
|
||||
self.workflow_finished = value
|
||||
|
||||
def _find_next_not_started_task(self) -> Optional[Task]:
|
||||
"""Fallback mechanism to find the next 'not started' task."""
|
||||
@@ -235,9 +259,10 @@ class Process:
|
||||
temp_current_task = None
|
||||
|
||||
# Clear previous task context before finding next task
|
||||
# NOTE: Fixed to only clear _execution_context instead of mutating descriptions
|
||||
for task in self.tasks.values():
|
||||
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
|
||||
task.description = task.description.split('Input data from previous tasks:')[0].strip()
|
||||
if hasattr(task, '_execution_context'):
|
||||
task._execution_context = None
|
||||
|
||||
while fallback_attempts < self.max_retries and not temp_current_task:
|
||||
fallback_attempts += 1
|
||||
@@ -253,15 +278,18 @@ class Process:
|
||||
|
||||
if not leads_to_task and not task_candidate.next_tasks:
|
||||
continue # Skip if no valid path exists
|
||||
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
|
||||
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
|
||||
# Fix TOCTOU bug: Move the entire check-and-increment inside the lock
|
||||
with self._state_lock_init:
|
||||
current_count = self.task_retry_counter.get(task_candidate.id, 0)
|
||||
if current_count < self.max_retries:
|
||||
self.task_retry_counter[task_candidate.id] = current_count + 1
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
if not temp_current_task:
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
|
||||
return None # Return None if no task found after all attempts
|
||||
@@ -373,7 +401,7 @@ class Process:
|
||||
# Parse JSON and validate with Pydantic
|
||||
return self._parse_manager_instructions(response, ManagerInstructions)
|
||||
|
||||
def _check_all_tasks_completed(self) -> bool:
|
||||
async def _check_all_tasks_completed(self) -> bool:
|
||||
"""Check if all tasks are completed and handle workflow completion.
|
||||
|
||||
Returns:
|
||||
@@ -381,9 +409,27 @@ class Process:
|
||||
"""
|
||||
if all(task.status == "completed" for task in self.tasks.values()):
|
||||
logging.info("All tasks are completed.")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_all_tasks_completed_sync(self) -> bool:
|
||||
"""Synchronous version of _check_all_tasks_completed.
|
||||
|
||||
Returns:
|
||||
bool: True if all tasks are completed and workflow should exit, False otherwise.
|
||||
"""
|
||||
if all(task.status == "completed" for task in self.tasks.values()):
|
||||
logging.info("All tasks are completed.")
|
||||
# Use thread-safe setter for sync context
|
||||
self._set_workflow_finished_sync(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _set_workflow_finished_sync(self, value: bool):
|
||||
"""Thread-safe setter for workflow_finished flag in sync context."""
|
||||
with self._state_lock_init:
|
||||
self.workflow_finished = value
|
||||
|
||||
async def aworkflow(self) -> AsyncGenerator[str, None]:
|
||||
"""Async version of workflow method"""
|
||||
@@ -429,13 +475,19 @@ class Process:
|
||||
if self.workflow_timeout is not None:
|
||||
elapsed = time.monotonic() - workflow_start
|
||||
if elapsed > self.workflow_timeout:
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, cancelling workflow.")
|
||||
self.workflow_cancelled = True
|
||||
break
|
||||
|
||||
# ADDED: Check workflow finished flag at the start of each cycle
|
||||
if self.workflow_finished:
|
||||
logging.info("Workflow finished early as all tasks are completed.")
|
||||
break
|
||||
|
||||
# ADDED: Check workflow cancellation flag
|
||||
if self.workflow_cancelled:
|
||||
logging.warning("Workflow has been cancelled, stopping task execution.")
|
||||
break
|
||||
|
||||
# Add task summary at start of each cycle
|
||||
logging.debug(f"""
|
||||
@@ -454,7 +506,7 @@ Tasks by type:
|
||||
""")
|
||||
|
||||
# ADDED: Check if all tasks are completed and set workflow_finished flag
|
||||
if self._check_all_tasks_completed():
|
||||
if await self._check_all_tasks_completed():
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
task_id = current_task.id
|
||||
@@ -469,13 +521,10 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Build context and set description for this execution pass only
|
||||
# Build context and store separately instead of mutating description
|
||||
context = self._build_task_context(current_task)
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
# Store context in dedicated field instead of concatenating to description
|
||||
current_task._execution_context = context if context else ""
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -546,7 +595,7 @@ Subtask: {st.name}
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
current_task = next_task
|
||||
# Ensure the task is yielded for execution
|
||||
if current_task.id not in visited_tasks:
|
||||
@@ -555,7 +604,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# End workflow if no valid next task found
|
||||
logging.info(f"No valid next task found for decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -592,14 +641,14 @@ Subtask: {st.name}
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
logging.info(f"Task {current_task.name} has no next tasks, ending workflow")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
|
||||
# Reset completed task to "not started" so it can run again (atomic operation)
|
||||
if self._state_lock is None:
|
||||
self._state_lock = asyncio.Lock()
|
||||
async with self._state_lock:
|
||||
# Get async state lock (created within async context)
|
||||
lock = await self._get_state_lock()
|
||||
async with lock:
|
||||
if self.tasks[task_id].status == "completed":
|
||||
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
|
||||
subtask_name = self.tasks[task_id].name
|
||||
@@ -655,7 +704,7 @@ Subtask: {st.name}
|
||||
# Handle all forms of exit conditions
|
||||
if not target_tasks or target_tasks == "exit" or (isinstance(target_tasks, list) and (not target_tasks or target_tasks[0] == "exit")):
|
||||
logging.info(f"Workflow exit condition met on decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -695,7 +744,7 @@ Subtask: {st.name}
|
||||
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
# Don't mark workflow as finished when following condition path
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
@@ -709,7 +758,7 @@ Subtask: {st.name}
|
||||
next_task.next_tasks and
|
||||
next_task.next_tasks[0] in self.tasks and
|
||||
next_task.name in self.tasks[next_task.next_tasks[0]].previous_tasks):
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
@@ -787,6 +836,11 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
invalid_selection_attempts = 0
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
@@ -813,22 +867,44 @@ Provide a JSON with the structure:
|
||||
"agent_name": "<string>",
|
||||
"action": "<execute or stop>"
|
||||
}}
|
||||
"""
|
||||
""" + error_context
|
||||
|
||||
try:
|
||||
logging.info("Requesting manager instructions...")
|
||||
if manager_task.async_execution:
|
||||
parsed_instructions = await self._get_manager_instructions_with_fallback_async(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
else:
|
||||
parsed_instructions = self._get_manager_instructions_with_fallback(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
logging.info(f"Manager instructions: {parsed_instructions}")
|
||||
except Exception as e:
|
||||
display_error(f"Manager parse error: {e}")
|
||||
logging.error(f"Manager parse error: {str(e)}", exc_info=True)
|
||||
# Retry logic for manager instruction failures
|
||||
MAX_MANAGER_RETRIES = 3
|
||||
parsed_instructions = None
|
||||
|
||||
for manager_attempt in range(MAX_MANAGER_RETRIES):
|
||||
try:
|
||||
logging.info(f"Requesting manager instructions (attempt {manager_attempt + 1}/{MAX_MANAGER_RETRIES})...")
|
||||
if manager_task.async_execution:
|
||||
parsed_instructions = await self._get_manager_instructions_with_fallback_async(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
else:
|
||||
# Offload sync call to thread to avoid blocking event loop
|
||||
parsed_instructions = await asyncio.to_thread(
|
||||
self._get_manager_instructions_with_fallback,
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
logging.info(f"Manager instructions: {parsed_instructions}")
|
||||
break # Success, exit retry loop
|
||||
except Exception as e:
|
||||
if manager_attempt < MAX_MANAGER_RETRIES - 1:
|
||||
# Not the last attempt, wait with exponential backoff
|
||||
delay = 2 ** manager_attempt
|
||||
logging.warning(
|
||||
f"Manager parse error (attempt {manager_attempt + 1}/{MAX_MANAGER_RETRIES}), "
|
||||
f"retrying in {delay}s: {e}"
|
||||
)
|
||||
# Always use async sleep in async context
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
# Final attempt failed
|
||||
display_error(f"Manager failed after {MAX_MANAGER_RETRIES} attempts: {e}")
|
||||
logging.error(f"Manager parse error: {str(e)}", exc_info=True)
|
||||
|
||||
# If all retries failed, break out of the main loop
|
||||
if parsed_instructions is None:
|
||||
break
|
||||
|
||||
selected_task_id = parsed_instructions.task_id
|
||||
@@ -842,10 +918,29 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
error_msg = f"Manager selected invalid task id {selected_task_id}"
|
||||
display_error(error_msg)
|
||||
logging.error(error_msg)
|
||||
break
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
)
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
# Reset on valid selection
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
for a in self.agents:
|
||||
@@ -899,6 +994,7 @@ Provide a JSON with the structure:
|
||||
stacklevel=3
|
||||
)
|
||||
current_iter = 0 # Track how many times we've looped
|
||||
workflow_start = time.monotonic() # For timeout enforcement
|
||||
# Build workflow relationships first
|
||||
for task in self.tasks.values():
|
||||
if task.next_tasks:
|
||||
@@ -1027,10 +1123,23 @@ Provide a JSON with the structure:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
|
||||
break
|
||||
|
||||
# Enforce workflow timeout if set
|
||||
if self.workflow_timeout is not None:
|
||||
elapsed = time.monotonic() - workflow_start
|
||||
if elapsed > self.workflow_timeout:
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, ending workflow.")
|
||||
self.workflow_cancelled = True
|
||||
break
|
||||
|
||||
# ADDED: Check workflow finished flag at the start of each cycle
|
||||
if self.workflow_finished:
|
||||
logging.info("Workflow finished early as all tasks are completed.")
|
||||
break
|
||||
|
||||
# ADDED: Check workflow cancellation flag
|
||||
if self.workflow_cancelled:
|
||||
logging.warning("Workflow has been cancelled, stopping task execution.")
|
||||
break
|
||||
|
||||
# Add task summary at start of each cycle
|
||||
logging.debug(f"""
|
||||
@@ -1049,7 +1158,7 @@ Tasks by type:
|
||||
""")
|
||||
|
||||
# ADDED: Check if all tasks are completed and set workflow_finished flag
|
||||
if self._check_all_tasks_completed():
|
||||
if self._check_all_tasks_completed_sync():
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
|
||||
@@ -1138,13 +1247,10 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Build context and set description for this execution pass only
|
||||
# Build context and store separately instead of mutating description
|
||||
context = self._build_task_context(current_task)
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
# Store context in dedicated field instead of concatenating to description
|
||||
current_task._execution_context = context if context else ""
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1215,7 +1321,7 @@ Subtask: {st.name}
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
current_task = next_task
|
||||
# Ensure the task is yielded for execution
|
||||
if current_task.id not in visited_tasks:
|
||||
@@ -1224,7 +1330,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# End workflow if no valid next task found
|
||||
logging.info(f"No valid next task found for decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -1261,7 +1367,7 @@ Subtask: {st.name}
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
logging.info(f"Task {current_task.name} has no next tasks, ending workflow")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
|
||||
@@ -1322,7 +1428,7 @@ Subtask: {st.name}
|
||||
# Handle all forms of exit conditions
|
||||
if not target_tasks or target_tasks == "exit" or (isinstance(target_tasks, list) and (not target_tasks or target_tasks[0] == "exit")):
|
||||
logging.info(f"Workflow exit condition met on decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -1362,7 +1468,7 @@ Subtask: {st.name}
|
||||
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
# Don't mark workflow as finished when following condition path
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
@@ -1376,7 +1482,7 @@ Subtask: {st.name}
|
||||
next_task.next_tasks and
|
||||
next_task.next_tasks[0] in self.tasks and
|
||||
next_task.name in self.tasks[next_task.next_tasks[0]].previous_tasks):
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
@@ -1421,7 +1527,19 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
def sequential(self):
|
||||
"""Synchronous version of sequential method"""
|
||||
for task_id in self.tasks:
|
||||
if self.tasks[task_id].status != "completed":
|
||||
task = self.tasks[task_id]
|
||||
if task.status == "failed":
|
||||
continue # Skip failed tasks
|
||||
# Check if any context dependency failed
|
||||
if hasattr(task, 'context') and task.context:
|
||||
deps_failed = any(
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
if deps_failed:
|
||||
task.status = "failed"
|
||||
continue
|
||||
if task.status != "completed":
|
||||
yield task_id
|
||||
|
||||
def hierarchical(self):
|
||||
@@ -1454,6 +1572,11 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
invalid_selection_attempts = 0
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
@@ -1480,7 +1603,7 @@ Provide a JSON with the structure:
|
||||
"agent_name": "<string>",
|
||||
"action": "<execute or stop>"
|
||||
}}
|
||||
"""
|
||||
""" + error_context
|
||||
|
||||
try:
|
||||
logging.info("Requesting manager instructions...")
|
||||
@@ -1504,10 +1627,29 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
error_msg = f"Manager selected invalid task id {selected_task_id}"
|
||||
display_error(error_msg)
|
||||
logging.error(error_msg)
|
||||
break
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
)
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
# Reset on valid selection
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
for a in self.agents:
|
||||
@@ -1528,4 +1670,4 @@ Provide a JSON with the structure:
|
||||
self.tasks[manager_task.id].status = "completed"
|
||||
if self.verbose >= 1:
|
||||
logging.info("All tasks completed under manager supervision.")
|
||||
logging.info("Hierarchical task execution finished")
|
||||
logging.info("Hierarchical task execution finished")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Push client public surface.
|
||||
|
||||
Concrete implementations live in the `praisonai` wrapper. This module
|
||||
re-exports them lazily so user code can keep importing from
|
||||
``praisonaiagents.push``.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
from .models import ChannelMessage
|
||||
from .protocols import PushTransportProtocol
|
||||
|
||||
_lazy_cache: dict = {}
|
||||
|
||||
_WRAPPER_MODULE = "praisonai.push"
|
||||
_WRAPPER_NAMES = frozenset({"PushClient", "WebSocketTransport", "PollingTransport"})
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
if name in _WRAPPER_NAMES:
|
||||
try:
|
||||
obj = getattr(importlib.import_module(_WRAPPER_MODULE), name)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"{name} requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
) from e
|
||||
_lazy_cache[name] = obj
|
||||
return obj
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChannelMessage",
|
||||
"PushTransportProtocol",
|
||||
"PushClient",
|
||||
"WebSocketTransport",
|
||||
"PollingTransport",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Backward compatibility shim for praisonaiagents.push.client.
|
||||
|
||||
This module maintains backward compatibility for existing imports like:
|
||||
from praisonaiagents.push.client import PushClient
|
||||
|
||||
The actual implementation has been moved to the praisonai wrapper package.
|
||||
"""
|
||||
from . import PushClient
|
||||
|
||||
__all__ = ["PushClient"]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Push notification models for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelMessage:
|
||||
"""A user-friendly wrapper around a push channel message.
|
||||
|
||||
Attributes:
|
||||
channel: Channel name the message was received on
|
||||
data: Message payload
|
||||
event_id: Unique event identifier
|
||||
timestamp: When the event was created
|
||||
source: Source client ID (who sent it)
|
||||
"""
|
||||
|
||||
channel: str
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
source: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_event_dict(cls, event_dict: Dict[str, Any]) -> "ChannelMessage":
|
||||
"""Create from a raw gateway event dictionary."""
|
||||
return cls(
|
||||
channel=event_dict.get("channel", ""),
|
||||
data=event_dict.get("data", {}),
|
||||
event_id=event_dict.get("event_id", str(uuid.uuid4())),
|
||||
timestamp=event_dict.get("timestamp", time.time()),
|
||||
source=event_dict.get("source"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"data": self.data,
|
||||
"event_id": self.event_id,
|
||||
"timestamp": self.timestamp,
|
||||
"source": self.source,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Push transport protocol — implementations live in the praisonai wrapper."""
|
||||
from __future__ import annotations
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PushTransportProtocol(Protocol):
|
||||
"""Protocol for push client transports."""
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Whether the transport is currently connected."""
|
||||
...
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Establish connection."""
|
||||
...
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close connection."""
|
||||
...
|
||||
|
||||
async def send(self, data: Dict[str, Any]) -> None:
|
||||
"""Send a JSON message."""
|
||||
...
|
||||
|
||||
async def receive(self) -> Dict[str, Any]:
|
||||
"""Receive a JSON message. Blocks until a message arrives."""
|
||||
...
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Backward compatibility shim for praisonaiagents.push.transports.
|
||||
|
||||
This module maintains backward compatibility for existing imports like:
|
||||
from praisonaiagents.push.transports import WebSocketTransport, PollingTransport
|
||||
|
||||
The actual implementations have been moved to the praisonai wrapper package.
|
||||
"""
|
||||
from . import WebSocketTransport, PollingTransport
|
||||
|
||||
__all__ = ["WebSocketTransport", "PollingTransport"]
|
||||
@@ -67,21 +67,28 @@ _LAZY_IMPORTS = {
|
||||
"deduplicate_chunks": ("praisonaiagents.rag.context", "deduplicate_chunks"),
|
||||
}
|
||||
|
||||
import threading
|
||||
|
||||
_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load RAG components."""
|
||||
"""Lazy load RAG components with thread safety."""
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr_name = _LAZY_IMPORTS[name]
|
||||
import importlib
|
||||
module = importlib.import_module(module_path)
|
||||
value = getattr(module, attr_name)
|
||||
_cache[name] = value
|
||||
return value
|
||||
with _cache_lock:
|
||||
# Double-check after acquiring lock
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
module_path, attr_name = _LAZY_IMPORTS[name]
|
||||
import importlib
|
||||
module = importlib.import_module(module_path)
|
||||
value = getattr(module, attr_name)
|
||||
_cache[name] = value
|
||||
return value
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import uuid
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -51,7 +52,8 @@ class Session:
|
||||
agent_url: Optional[str] = None,
|
||||
memory_config: Optional[Dict[str, Any]] = None,
|
||||
knowledge_config: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30
|
||||
timeout: int = 30,
|
||||
session_ttl: Optional[int] = None # Gap 2b: TTL in seconds
|
||||
):
|
||||
"""
|
||||
Initialize a new session with optional persistence or remote agent connectivity.
|
||||
@@ -63,12 +65,14 @@ class Session:
|
||||
memory_config: Configuration for memory system (defaults to RAG)
|
||||
knowledge_config: Configuration for knowledge base system
|
||||
timeout: HTTP timeout for remote agent calls (default: 30 seconds)
|
||||
session_ttl: Time-to-live in seconds after which session expires (Gap 2b)
|
||||
"""
|
||||
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
self.user_id = user_id or "default_user"
|
||||
self.agent_url = agent_url
|
||||
self.timeout = timeout
|
||||
self.is_remote = agent_url is not None
|
||||
self._state_lock = threading.RLock() # Reentrant lock for thread-safe state operations
|
||||
|
||||
# Validate agent_url format
|
||||
if self.is_remote:
|
||||
@@ -110,6 +114,10 @@ class Session:
|
||||
self._agents_instance = None
|
||||
self._agents = {} # Track agents and their chat histories
|
||||
|
||||
# Gap 2b: Session TTL and cleanup support
|
||||
self.session_ttl = session_ttl
|
||||
self._created_at = time.time() # Track creation time for TTL
|
||||
|
||||
def _get_session_dir(self):
|
||||
"""Return session-specific directory using paths.py."""
|
||||
from pathlib import Path
|
||||
@@ -186,8 +194,8 @@ class Session:
|
||||
|
||||
agent = Agent(**agent_kwargs)
|
||||
|
||||
# Create a unique key for this agent (using name and role)
|
||||
agent_key = f"{name}:{role}"
|
||||
# Create a unique key for this agent (Gap 2a fix: include session_id for proper isolation)
|
||||
agent_key = f"{self.session_id}:{name}:{role}"
|
||||
|
||||
# Restore chat history if it exists from previous sessions
|
||||
if agent_key in self._agents:
|
||||
@@ -270,7 +278,7 @@ class Session:
|
||||
|
||||
def _restore_agent_chat_history(self, agent_key: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Restore agent chat history from memory.
|
||||
Restore agent chat history from SessionStore first, then memory fallback (Gap 2c fix).
|
||||
|
||||
Args:
|
||||
agent_key: Unique identifier for the agent
|
||||
@@ -281,7 +289,21 @@ class Session:
|
||||
if self.is_remote:
|
||||
return []
|
||||
|
||||
# Search for agent chat history in memory
|
||||
# Gap 2c: Try SessionStore first for clean separation
|
||||
try:
|
||||
from .session.store import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
chat_history = session_store.get_chat_history(agent_key)
|
||||
if not chat_history:
|
||||
# Backward compatibility: fall back to legacy
|
||||
# "{session_id}_{agent_key}" format for existing stored conversations.
|
||||
chat_history = session_store.get_chat_history(f"{self.session_id}_{agent_key}")
|
||||
if chat_history:
|
||||
return chat_history
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: Search for agent chat history in memory (backward compatibility)
|
||||
results = self.memory.search_short_term(
|
||||
query="Agent chat history for",
|
||||
limit=10
|
||||
@@ -350,21 +372,20 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
# G-2 FIX: Use SessionStore for clean separation (Gap 2c fix)
|
||||
session_store = None
|
||||
try:
|
||||
from .session import get_default_session_store
|
||||
from .session.store import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
agent_key,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
@@ -384,19 +405,24 @@ class Session:
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
state = self.restore_state()
|
||||
return state.get(key, default)
|
||||
with self._state_lock:
|
||||
state = self.restore_state()
|
||||
return state.get(key, default)
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a specific state value"""
|
||||
current_state = self.restore_state()
|
||||
current_state[key] = value
|
||||
self.save_state(current_state)
|
||||
with self._state_lock:
|
||||
current_state = self.restore_state()
|
||||
current_state[key] = value
|
||||
self.save_state(current_state)
|
||||
|
||||
def increment_state(self, key: str, increment: int = 1, default: int = 0) -> None:
|
||||
"""Increment a numeric state value"""
|
||||
current_value = self.get_state(key, default)
|
||||
self.set_state(key, current_value + increment)
|
||||
"""Increment a numeric state value atomically"""
|
||||
with self._state_lock:
|
||||
current_state = self.restore_state()
|
||||
current_value = current_state.get(key, default)
|
||||
current_state[key] = current_value + increment
|
||||
self.save_state(current_state)
|
||||
|
||||
def add_memory(self, text: str, memory_type: str = "long", **metadata) -> None:
|
||||
"""
|
||||
@@ -580,10 +606,50 @@ class Session:
|
||||
"""
|
||||
return self.chat(message, **kwargs)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the session has expired based on TTL (Gap 2b fix)."""
|
||||
if self.session_ttl is None:
|
||||
return False
|
||||
return time.time() - self._created_at > self.session_ttl
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close and cleanup the session (Gap 2b fix)."""
|
||||
if self.is_remote:
|
||||
return # No cleanup needed for remote sessions
|
||||
|
||||
# Properly cleanup memory
|
||||
if self._memory:
|
||||
try:
|
||||
# Call close_connections to properly cleanup memory adapters
|
||||
if hasattr(self._memory, 'close_connections'):
|
||||
self._memory.close_connections()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Memory cleanup failed: {e}")
|
||||
finally:
|
||||
self._memory = None
|
||||
|
||||
# Clear knowledge
|
||||
if self._knowledge:
|
||||
try:
|
||||
self._knowledge = None
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Clear agents
|
||||
self._agents.clear()
|
||||
|
||||
def time_to_expiry(self) -> Optional[float]:
|
||||
"""Get seconds until session expires, or None if no TTL set (Gap 2b)."""
|
||||
if self.session_ttl is None:
|
||||
return None
|
||||
elapsed = time.time() - self._created_at
|
||||
return max(0, self.session_ttl - elapsed)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.is_remote:
|
||||
return f"Session(id='{self.session_id}', user='{self.user_id}', remote_agent='{self.agent_url}')"
|
||||
return f"Session(id='{self.session_id}', user='{self.user_id}')"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
return self.__str__()
|
||||
@@ -481,6 +481,74 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
session.title = title
|
||||
return self._save_extended_session(session)
|
||||
|
||||
async def auto_title(self, session_id: str) -> bool:
|
||||
"""Generate and set title automatically from first exchange.
|
||||
|
||||
Args:
|
||||
session_id: Session to generate title for
|
||||
|
||||
Returns:
|
||||
True if title was generated and set, False otherwise
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Load session in thread to avoid blocking event loop
|
||||
session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
|
||||
# Skip if already has a title
|
||||
if session.title and session.title.strip():
|
||||
return False
|
||||
|
||||
# Need at least one user and one assistant message
|
||||
messages = session.messages
|
||||
if not messages or len(messages) < 2:
|
||||
return False
|
||||
|
||||
# Find first user message and first assistant response
|
||||
user_msg = None
|
||||
assistant_msg = None
|
||||
|
||||
for msg in messages:
|
||||
# Handle both SessionMessage dataclass and dict formats
|
||||
if hasattr(msg, 'role'):
|
||||
role = msg.role
|
||||
content = msg.content
|
||||
else:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "user" and not user_msg:
|
||||
if isinstance(content, str) and content.strip():
|
||||
user_msg = content
|
||||
elif role == "assistant" and not assistant_msg and user_msg:
|
||||
if isinstance(content, str) and content.strip():
|
||||
assistant_msg = content
|
||||
break
|
||||
|
||||
if not user_msg or not assistant_msg:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Generate title using title module
|
||||
from .title import generate_title_async
|
||||
title = await generate_title_async(user_msg, assistant_msg)
|
||||
|
||||
if title and title.strip():
|
||||
# Reload session to avoid overwriting concurrent updates
|
||||
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
return await asyncio.to_thread(self._save_extended_session, fresh_session)
|
||||
|
||||
except Exception as e:
|
||||
# Title generation failed - log with context instead of silent failure
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Auto title generation failed for session %s: %s", session_id, str(e))
|
||||
|
||||
return False
|
||||
|
||||
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Get extended session data."""
|
||||
return self._load_extended_session(session_id)
|
||||
|
||||
@@ -5,7 +5,6 @@ JSON-based session persistence with file locking and atomic writes.
|
||||
Zero dependencies beyond stdlib.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -14,6 +13,13 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
# fcntl is Unix-only; on Windows, use msvcrt for file locking
|
||||
if sys.platform != 'win32':
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
else:
|
||||
_HAS_FCNTL = False
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -22,6 +28,9 @@ from ..paths import get_sessions_dir
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Module-level sentinel to track if we've warned about degraded locking
|
||||
_WARNED_NO_FCNTL = False
|
||||
|
||||
# Default session directory (uses centralized paths - DRY)
|
||||
DEFAULT_SESSION_DIR = str(get_sessions_dir())
|
||||
|
||||
@@ -150,7 +159,17 @@ class FileLock:
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
# Unix locking
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Warn once about degraded locking (should never happen with current platform detection)
|
||||
global _WARNED_NO_FCNTL
|
||||
if not _WARNED_NO_FCNTL:
|
||||
logger.warning(
|
||||
"File locking unavailable on this platform (no fcntl/msvcrt); "
|
||||
"concurrent writers may corrupt session files."
|
||||
)
|
||||
_WARNED_NO_FCNTL = True
|
||||
return True
|
||||
except (IOError, OSError, BlockingIOError):
|
||||
if self._lock_file:
|
||||
@@ -171,7 +190,9 @@ class FileLock:
|
||||
import msvcrt
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
|
||||
# Note: No warning needed in release() as it mirrors acquire() logic
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
finally:
|
||||
@@ -490,7 +511,7 @@ class DefaultSessionStore:
|
||||
pass
|
||||
|
||||
# Sort by updated_at descending
|
||||
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
||||
sessions.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
# ── Agent-Level Queries (Gap S4) ──────────────────────────────────
|
||||
|
||||
Loaded 100 of 514 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user