Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 3e9826bda7 fix: resolve missing AgentTeam import and RouterAgent kwargs forwarding
- Fix missing AgentTeam import in self-reflection-optimization-patterns.py
- Forward kwargs properly in RouterAgent._build_system_prompt to super()
- Addresses critical issues identified by Gemini code reviewer

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
praisonai-triage-agent[bot]andMervin Praison fc298049e2 fix: resolve critical RouterAgent **kwargs bug and add missing if __name__ guards
- Fixed TypeError in RouterAgent._build_system_prompt() by removing **kwargs from super() call
- Added if __name__ == '__main__' guards to 6 example files to prevent auto-execution on import
- Addresses Gemini Code Assist review feedback

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
Dhivya-Bharathy 0b7bf10820 fix(examples): phase-4 API alignment for 40 validation failures on v4.6.52 2026-06-12 23:25:32 +00:00
Dhivya-Bharathy cbb7c726aa fix(examples): phase-3 API alignment for consolidated params on v4.6.52 2026-06-12 23:25:12 +00:00
Dhivya-Bharathy 21d1986d0c fix(examples): align AgentTeam memory and knowledge configs with v4.6.52 API 2026-06-12 23:25:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 26b5792192 fix: correct TypeError in 3 example files by using proper parameter names
- Fix MemoryConfig(memory=...) → user_id=... in agent_centric_api.py
- Fix Session(memory=...) → user_id=... in session-example.py
- Fix build_context_for_task(memory=...) → user_id=... in memory-quality-example.py

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:12 +00:00
Dhivya-Bharathy afcea5800e fix(examples): resolve syntax errors and align with consolidated-params API 2026-06-12 23:25:12 +00:00
546 changed files with 12000 additions and 67638 deletions

No files matched your search

+17 -85
View File
@@ -15,7 +15,7 @@ on:
pull_request_review:
types: [submitted]
pull_request:
types: [opened, synchronize]
types: [opened]
env:
MAX_POLL_ATTEMPTS: 20
@@ -143,12 +143,9 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
core.setOutput('comments_count', comments.data.length.toString());
@@ -189,7 +186,7 @@ jobs:
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, SDK value gate — no feature bloat).\n' +
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
'Phase 2: FIX valid issues found by prior reviewers.\n' +
'Phase 3: Provide final verdict (approve or request changes).';
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
@@ -298,12 +295,9 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
@@ -319,7 +313,7 @@ jobs:
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. 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}`);
@@ -405,12 +399,9 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
@@ -426,7 +417,7 @@ jobs:
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. 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}`);
@@ -462,12 +453,9 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Post Claude analysis request (Lead Engineer)
@@ -480,65 +468,9 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. 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'
});
# ================================================================
# Recovery: post FINAL @claude if merge-conflict comment blocked the chain
# (e.g. conflict fired before claude-after-copilot could post review prompt)
# ================================================================
claude-review-recovery:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Post missing FINAL Claude review after rebase
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const { data: comments } = await github.rest.issues.listComments({
issue_number: pr, owner, repo, per_page: 100,
});
const hasFinalReview = comments.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
if (hasFinalReview) {
core.info('FINAL Claude review already posted, skipping recovery');
return;
}
const hasReviewerActivity = comments.some(c => {
const login = c.user.login.toLowerCase();
return login.includes('coderabbit') || login.includes('copilot') ||
login.includes('greptile') || login.includes('qodo') || login.includes('gemini');
});
if (!hasReviewerActivity) {
core.info('No bot reviewer activity yet, skipping recovery');
return;
}
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
core.info(`Posted recovery FINAL Claude review on PR #${pr}`);
# ================================================================
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
# Trigger them explicitly. Do NOT trigger Copilot here — it will
+39 -80
View File
@@ -151,67 +151,26 @@ jobs:
id: check_fork
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const isPR = context.eventName === 'pull_request'
|| context.eventName === 'pull_request_review'
|| context.eventName === 'pull_request_review_comment'
|| (context.payload.issue && context.payload.issue.pull_request);
if (!isPR) {
core.setOutput('is_pr', 'false');
core.setOutput('is_fork', 'false');
core.setOutput('can_push_fork', 'false');
return;
}
const prNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
let mergeState = '';
try {
const gql = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) { mergeStateStatus }
}
}`,
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
);
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
} catch (e) {
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
}
if (!mergeState) {
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
}
core.setOutput('is_pr', 'true');
core.setOutput('pr_number', String(prNumber));
core.setOutput('pr_branch', pr.data.head.ref);
core.setOutput('head_repo', pr.data.head.repo.full_name);
core.setOutput('merge_state', mergeState);
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
const maintainerCanModify = pr.data.maintainer_can_modify === true;
core.setOutput('maintainer_can_modify', String(maintainerCanModify));
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
core.setOutput('is_fork', 'true');
core.setOutput('can_push_fork', String(maintainerCanModify));
core.setOutput('fork_repo', pr.data.head.repo.full_name);
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
const isPR = context.eventName === 'pull_request' || (context.payload.issue && context.payload.issue.pull_request);
if (isPR) {
const prNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('pr_branch', pr.data.head.ref);
if (pr.data.head.repo && pr.data.head.repo.full_name !== context.repo.full_name) {
core.setOutput('is_fork', 'true');
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
}
}
core.setOutput('is_fork', 'false');
core.setOutput('can_push_fork', 'false');
- name: Checkout repository
uses: actions/checkout@v4
@@ -221,40 +180,31 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
- name: Fetch PR branch and Setup Remote
if: steps.check_fork.outputs.is_pr == 'true'
if: github.event.issue.pull_request
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
CAN_PUSH_FORK: ${{ steps.check_fork.outputs.can_push_fork }}
FORK_REPO: ${{ steps.check_fork.outputs.fork_repo }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
git checkout "${PR_BRANCH}"
git fetch origin main
if [ "${IS_FORK}" = "true" ] && [ "${CAN_PUSH_FORK}" = "true" ]; then
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git"
elif [ "${IS_FORK}" = "true" ]; then
# Block push: trick claude-code-action's `git fetch origin <branch>`
git remote set-url origin "file://$(pwd)"
# Fetch PR head from base repo and put it into local branch
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${IS_FORK}" = "true" ]; then
git remote set-url origin file://$(pwd)
fi
- uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: "@claude"
label_trigger: "claude"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && steps.check_fork.outputs.can_push_fork != 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK WITHOUT MAINTAINER EDITS. YOU DO NOT HAVE PUSH PERMISSIONS. ONLY READ FILES, VALIDATE CODE, AND PROVIDE COMMENTS/FEEDBACK ON THIS PR. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.can_push_fork == 'true' && format('NOTE: Fork PR with maintainer edits allowed. Push directly to fork {0} branch {1}.\n\n', steps.check_fork.outputs.fork_repo, steps.check_fork.outputs.pr_branch) || (steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || (steps.check_fork.outputs.is_pr != 'true' && 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n' || ''))) }}
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, can_push_fork={4}, merge_state={5}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.can_push_fork, steps.check_fork.outputs.merge_state) || '' }}
${{ 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):
@@ -283,9 +233,15 @@ jobs:
- Is there a name collision with existing exports in __init__.py?
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.
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: 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)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
- 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.
@@ -311,7 +267,10 @@ jobs:
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
${{ steps.check_fork.outputs.is_pr == 'true' && 'STEP 5 — SKIP: Already on an open PR. Do NOT run gh pr create.\n' || 'STEP 5 — CREATE PR:\n- Commit with descriptive message, push, and create PR using `gh pr create`\n- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`\nCRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".\n' }}
STEP 5 — CREATE PR:
- Commit with descriptive message, push, and create PR using `gh pr create`
- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".
NOTE: If you worked on an external repo, the wrapper action will report "No commits" for the main repo at the end. This is expected, do not worry about it.
allowed_tools: |
Bash(git:*)
@@ -1,52 +0,0 @@
name: Deploy install scripts to praison.ai
# When install.sh or install.ps1 change on main, trigger praison.ai CI which
# syncs scripts at build time and deploys to Azure AKS (existing azure-pipeline.yaml).
on:
push:
branches: [main]
paths:
- 'src/praisonai/scripts/install.sh'
- 'src/praisonai/scripts/install.ps1'
workflow_dispatch:
concurrency:
group: deploy-install-scripts
cancel-in-progress: false
permissions:
contents: read
jobs:
trigger-website-deploy:
runs-on: ubuntu-latest
steps:
- name: Trigger praison.ai CI
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "SECRETS_ADMIN_PAT is not configured in this repository"
exit 1
fi
gh workflow run CI \
--repo MervinPraison/praison.ai \
--ref master
echo "Triggered praison.ai CI for PraisonAI commit ${SOURCE_SHA}"
echo "Monitor: https://github.com/MervinPraison/praison.ai/actions/workflows/azure-pipeline.yaml"
sleep 10
RUN_URL=$(gh run list \
--repo MervinPraison/praison.ai \
--workflow CI \
--limit 1 \
--json url,status,createdAt \
--jq '.[0].url' 2>/dev/null || true)
if [ -n "${RUN_URL}" ]; then
echo "Latest praison.ai CI run: ${RUN_URL}"
fi
+2
View File
@@ -22,6 +22,8 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -36,6 +36,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Run Gemini Issue Triage
@@ -37,6 +37,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Set up Git
+3
View File
@@ -48,6 +48,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
ref: ${{ steps.checkout_ref.outputs.ref }}
fetch-depth: 0
+4 -4
View File
@@ -60,16 +60,16 @@ jobs:
- name: Run PraisonAI integration tests
run: |
cd PraisonAI/src/praisonai
python -m pytest tests/integration/test_aiui_* -v
python -m pytest tests/integration/test_aiui_* -v --timeout=300
- name: Run PraisonAIUI integration tests
run: |
cd PraisonAIUI
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
python -m pytest tests/integration/test_agentic_roundtrip.py -v
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
fi
if [ -f tests/test_feature_sdk_backends.py ]; then
python -m pytest tests/test_feature_sdk_backends.py -v
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
fi
- name: Test Pattern C CLI
@@ -152,7 +152,7 @@ jobs:
run: |
cd PraisonAI/src/praisonai
if [ -n "$OPENAI_API_KEY" ]; then
python -m pytest tests/integration/test_aiui_host_agentic.py -v
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
else
echo "OPENAI_API_KEY not available, skipping agentic tests"
fi
-183
View File
@@ -1,183 +0,0 @@
name: Auto Merge Conflict → Claude
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
permissions:
pull-requests: write
issues: write
jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
const LABEL = 'claude-conflict-pending';
const COMMENT_BODY = [
'@claude this PR has merge conflicts with `main`.',
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
'run targeted tests, and force-push with `--force-with-lease`.',
'Comment which files you resolved.'
].join(' ');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getMergeState(prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRef { repository { nameWithOwner } }
}
}
}
`;
for (let attempt = 0; attempt < 3; attempt++) {
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prGql = result.repository.pullRequest;
const status = (prGql?.mergeStateStatus || '').toUpperCase();
if (status && status !== 'UNKNOWN') {
return {
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.data.mergeable_state || '').toUpperCase(),
isDraft: pr.data.draft,
headRepo: pr.data.head.repo?.full_name,
maintainerCanModify: pr.data.maintainer_can_modify === true,
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
async function processPR(prNumber) {
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: prNumber,
});
const labels = issue.labels.map((l) => l.name);
if (status !== 'DIRTY' && labels.includes(LABEL)) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
}
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
if (isDraft) return { skipped: true, reason: 'draft' };
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
if (labels.includes(LABEL)) {
return { skipped: true, reason: 'conflict resolution pending' };
}
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
if (hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COMMENT_BODY,
});
return { triggered: true };
}
let prNumbers = [];
if (context.eventName === 'pull_request') {
prNumbers = [context.payload.pull_request.number];
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
prNumbers = prs.map((p) => p.number);
}
const results = [];
for (const num of prNumbers) {
results.push({ pr: num, ...(await processPR(num)) });
}
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
await core.summary.write();
-278
View File
@@ -1,278 +0,0 @@
name: PyPI Release
# Requires a GitHub Environment named "pypi" (Settings → Environments → pypi)
# with required reviewers before secrets are exposed to this job.
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for both packages'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
dry_run:
description: 'Preview versions and steps without publishing or pushing'
required: false
default: false
type: boolean
skip_agents:
description: 'Skip agents publish (recovery if already on PyPI)'
required: false
default: false
type: boolean
skip_wrapper:
description: 'Publish agents only'
required: false
default: false
type: boolean
agents_version:
description: 'Override agents version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
wrapper_version:
description: 'Override wrapper version (empty = auto bump from version.py)'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: pypi-release
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
run: |
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Compute release versions
id: versions
env:
BUMP: ${{ inputs.bump }}
AGENTS_OVERRIDE: ${{ inputs.agents_version }}
WRAPPER_OVERRIDE: ${{ inputs.wrapper_version }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
run: |
python <<'PY'
import os
import re
import sys
from pathlib import Path
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
root = Path(".")
bump = os.environ["BUMP"]
def bump_version(version: str) -> str:
parts = version.split(".")
if len(parts) != 3:
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
if bump == "major":
return f"{maj + 1}.0.0"
if bump == "minor":
return f"{maj}.{min_ + 1}.0"
return f"{maj}.{min_}.{patch + 1}"
def validate_version(label: str, value: str) -> None:
if value and not SEMVER.match(value):
print(f"Invalid {label} override: {value!r} (expected X.Y.Z)", file=sys.stderr)
sys.exit(1)
agents_override = os.environ.get("AGENTS_OVERRIDE", "").strip()
wrapper_override = os.environ.get("WRAPPER_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
agents_match = re.search(r'^version\s*=\s*"([^"]+)"', agents_content, re.MULTILINE)
if not agents_match:
print("Could not read agents version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_agents = agents_match.group(1)
if not SEMVER.match(current_agents):
print(f"Invalid current agents version: {current_agents}", file=sys.stderr)
sys.exit(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
if not wrapper_match:
print("Could not read wrapper version from version.py", file=sys.stderr)
sys.exit(1)
current_wrapper = wrapper_match.group(1)
if not SEMVER.match(current_wrapper):
print(f"Invalid current wrapper version: {current_wrapper}", file=sys.stderr)
sys.exit(1)
skip_agents = os.environ.get("SKIP_AGENTS", "false").lower() == "true"
if agents_override:
agents_version = agents_override
elif skip_agents:
agents_version = current_agents
else:
agents_version = bump_version(current_agents)
if wrapper_override:
wrapper_version = wrapper_override
else:
wrapper_version = bump_version(current_wrapper)
github_output = os.environ["GITHUB_OUTPUT"]
with open(github_output, "a", encoding="utf-8") as fh:
fh.write(f"agents_version={agents_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
echo "Would commit src/praisonai-agents/pyproject.toml and uv.lock"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
- name: Publish praisonaiagents
if: inputs.dry_run != true && inputs.skip_agents != true
working-directory: src/praisonai-agents
env:
NEW_VERSION: ${{ steps.versions.outputs.agents_version }}
CURRENT: ${{ steps.versions.outputs.current_agents }}
run: |
set -euo pipefail
python <<'PY'
import os
from pathlib import Path
path = Path("pyproject.toml")
content = path.read_text()
current = os.environ["CURRENT"]
new = os.environ["NEW_VERSION"]
path.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
PY
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for praisonaiagents on PyPI
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
PACKAGE="praisonaiagents"
VERSION="${{ steps.versions.outputs.agents_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
CURRENT=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
if [ "$CURRENT" = "$VERSION" ]; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} (current: ${CURRENT})"
exit 1
fi
echo "⏳ Current: ${CURRENT}, waiting for: ${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit agents version bump
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
git pull --rebase origin main
git push origin main
- name: Bump and release wrapper
if: inputs.dry_run != true && inputs.skip_wrapper != true
run: |
set -euo pipefail
python src/praisonai/scripts/bump_and_release.py "${{ steps.versions.outputs.wrapper_version }}" \
--agents "${{ steps.versions.outputs.agents_version }}" \
--wait --max-wait 600 --no-add-all
- name: Publish praisonai wrapper to PyPI
if: inputs.dry_run != true && inputs.skip_wrapper != true
working-directory: src/praisonai
run: uv publish
- name: Verify PyPI versions
if: inputs.dry_run != true
run: |
set -euo pipefail
AGENTS="${{ steps.versions.outputs.agents_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
if [ "${{ inputs.skip_agents }}" != "true" ]; then
LIVE=$(curl -fsSL "https://pypi.org/pypi/praisonaiagents/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
if [ "$LIVE" != "$AGENTS" ]; then
echo "❌ praisonaiagents expected ${AGENTS}, got ${LIVE}"
exit 1
fi
echo "✅ praisonaiagents==${AGENTS}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
LIVE=$(curl -fsSL "https://pypi.org/pypi/praisonai/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
if [ "$LIVE" != "$WRAPPER" ]; then
echo "❌ praisonai expected ${WRAPPER}, got ${LIVE}"
exit 1
fi
echo "✅ praisonai==${WRAPPER}"
fi
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
+2 -5
View File
@@ -8,18 +8,15 @@
name: Upload Python Package
# Superseded by pypi-release.yml (manual workflow_dispatch with uv publish).
# Kept as workflow_dispatch-only fallback; release trigger removed to avoid
# duplicate PyPI publish when bump_and_release creates a GitHub release.
on:
workflow_dispatch:
release:
types: [published]
permissions:
contents: read
jobs:
deploy:
if: false
runs-on: ubuntu-latest
+12 -3
View File
@@ -26,8 +26,11 @@ jobs:
# First checkout the upstream repo at the specified tag
- name: Checkout upstream repo
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
repository: MervinPraison/PraisonAI
ref: ${{ github.event.inputs.tag }}
fetch-depth: 0
@@ -45,8 +48,11 @@ jobs:
# Now checkout the fork at main branch
- name: Checkout fork
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
ref: main
fetch-depth: 0
@@ -443,8 +449,11 @@ jobs:
# First checkout the fork's main branch
- name: Checkout fork repo
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
@@ -1,31 +0,0 @@
name: Sync Claude Secrets to PraisonAIUI
on:
workflow_dispatch:
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync secrets to PraisonAIUI
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
TARGET: MervinPraison/PraisonAIUI
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "SECRETS_ADMIN_PAT is not configured in this repository"
exit 1
fi
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
+4 -2
View File
@@ -37,8 +37,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
+2 -2
View File
@@ -22,8 +22,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
+8 -4
View File
@@ -13,8 +13,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
@@ -79,8 +81,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
+4 -2
View File
@@ -33,8 +33,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
+16 -16
View File
@@ -45,8 +45,8 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
@@ -105,8 +105,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
@@ -187,8 +187,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -259,8 +259,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -318,8 +318,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -377,8 +377,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -436,8 +436,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -495,8 +495,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
+4 -2
View File
@@ -45,8 +45,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
-3
View File
@@ -88,9 +88,6 @@ issue_details.json
.internal
.praisonai
# Backup files
*.bak
# Rust / Cargo
target/
Cargo.lock
-10
View File
@@ -1,10 +0,0 @@
# Agent Instructions
You are working on the PraisonAI project.
## Project Guidelines
- Follow the existing code style and conventions
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design: core protocols in praisonaiagents/, heavy implementations in praisonai/
-437
View File
@@ -1,437 +0,0 @@
# PraisonAI Architecture
> **Last updated:** 2026-06-24
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers system design, runtime architecture, data contracts, reliability,
> observability, and the 2-quarter road map.
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [System Overview](#2-system-overview)
3. [Layered Architecture](#3-layered-architecture)
4. [Core Data Contracts](#4-core-data-contracts)
5. [Reliability by Design](#5-reliability-by-design)
6. [Observability & Telemetry](#6-observability--telemetry)
7. [Replay & Checkpointing](#7-replay--checkpointing)
8. [Implementation Roadmap](#8-implementation-roadmap)
9. [Success Metrics](#9-success-metrics)
---
## 1. Executive Summary
PraisonAI is a multi-agent AI framework with broad capability surface across
Python, TypeScript, and Rust SDKs. The framework's core strength is its
feature breadth — agent abstractions, integrations, workflows, and active
release cadence.
The strategic bet for the next two quarters is building a **Reliability +
Orchestration + Observability** core to unlock adoption and trust:
| Dimension | Current State | Target |
|-----------|--------------|--------|
| Reliability | Cross-platform mismatches, optional dep fragility | Deterministic gating, adapter abstraction, parity CI |
| Determinism | Flaky tests, global state collisions | Isolated test fixtures, traceable tenant IDs |
| Developer UX | Complex onboarding, implicit config | Golden-path CLI, Doctor Auto-Fix |
| Observability | Minimal runtime tracing | Structured event bus, replay engine, failure classifier |
### Design Principles
- **Deterministic by default**, flexible by opt-in
- **Policy-enforced execution boundaries** at every layer
- **Structured events** for all lifecycle transitions
- **Capability isolation** per agent and per tool
---
## 2. System Overview
```
User/SDK/CLI
→ Workflow Compiler (graph + policy + schemas)
→ Execution Orchestrator (state machine, retries, fallbacks)
→ Tool Runtime (sandbox + approval + capability scope)
→ Model Runtime (provider abstraction + rate/timeout policy)
→ Observability Bus (trace events + metrics + cost)
→ Replay Engine (checkpoint + deterministic re-run)
→ Persistence (session, memory, artifacts, run ledger)
```
### Runtime Components
| Component | Responsibility | Interface |
|-----------|---------------|-----------|
| API/CLI Gateway | Accepts run requests, validates config/profile | `RunRequest`, `RunProfile` |
| Workflow Compiler | Converts YAML/DSL to normalized execution graph | `CompiledGraph` |
| Orchestrator | Executes graph state machine with retries/fallbacks | `ExecutionState`, `StepTransition` |
| Tool Runtime | Runs tools with approval/sandbox policies | `ToolCall`, `ToolResult` |
| Model Runtime | Provider routing, timeout, budget control | `ModelRequest`, `ModelResponse` |
| Observability Bus | Streams structured events + metrics | `RunEvent` schema |
| Replay Engine | Restarts from checkpoint with deterministic inputs | `ReplayRequest` |
### Existing Architecture (Python SDK)
The Python SDK is the primary runtime and is organised as follows:
- `src/praisonai/` — Core runtime and agent abstractions
- `agents/` — Agent types (chat, code, realtime, audio, vision, etc.)
- `llm/` — Model runtime with provider routing, rate limiting, failover
- `tools/` — Tool runtime with sandbox, approval, retry
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `workflow/` — Workflow engine (YAML/SDK-based orchestration)
- `telemetry/` — Observability, monitoring, token tracking
- `reliability/` — Error classification, circuit breaker, failover
- `skill/` — Plugin/skill system
- `loop_detection/` — Doom loop detection and prevention
- `mcp/` — MCP protocol support
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols
### Multi-SDK Layout
- **Python SDK** — Primary runtime (`src/praisonai/`)
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
- **CLI** — Command-line interface (`cli/`)
- **UI** — Web UI (`ui/`)
---
## 3. Layered Architecture
```mermaid
flowchart TB
subgraph UX["Interface Layer"]
CLI[CLI]
SDK[SDK]
API[API Gateway]
end
subgraph Control["Control Plane"]
Compiler[Workflow Compiler]
Policy[Policy Engine]
Orchestrator[Execution Orchestrator]
end
subgraph Runtime["Runtime Plane"]
ModelRT[Model Runtime]
ToolRT[Tool Runtime]
MemoryRT[Memory Runtime]
end
subgraph Observe["Observability Plane"]
EventBus[Run Event Bus]
Metrics[Metrics + Cost]
Replay[Replay Engine]
end
subgraph Data["Data Plane"]
Ledger[Run Ledger]
Checkpoints[Checkpoints]
Artifacts[Artifacts Store]
end
CLI --> API
SDK --> API
API --> Compiler
API --> Policy
Compiler --> Orchestrator
Policy --> Orchestrator
Orchestrator --> ModelRT
Orchestrator --> ToolRT
Orchestrator --> MemoryRT
Orchestrator --> EventBus
EventBus --> Metrics
EventBus --> Replay
Orchestrator --> Ledger
Orchestrator --> Checkpoints
ToolRT --> Artifacts
Replay --> Checkpoints
```
### Interface Layer
- **CLI** — The `praisonai` command-line entry point
- **SDK** — Python library API (`from praisonai import Agent`)
- **API Gateway** — HTTP/WebSocket REST API (`praisonai api` or `a2a`/`a2ui`)
### Control Plane
- **Workflow Compiler** — Converts YAML workflows and SDK-defined graphs into
a normalized `CompiledGraph` with adjacency validation and cycle detection.
- **Policy Engine** — Enforces runtime policies (budget, timeout, approval
gates, capability validation) before and during execution.
- **Execution Orchestrator** — Drives the state machine through graph nodes,
handling retry, fallback, and failure classification.
### Runtime Plane
- **Model Runtime** — Provider abstraction layer. Routes to OpenAI, Anthropic,
Gemini, Ollama, DeepSeek, etc. Handles rate limiting, token tracking, and
automatic failover between providers.
- **Tool Runtime** — Executes tool calls with configurable sandboxing,
approval gates, retry policies, and capability scope validation.
- **Memory Runtime** — Manages session memory, persistent memory stores,
and knowledge retrieval across in-memory, SQLite, and MongoDB backends.
### Observability Plane
- **Run Event Bus** — Structured event streaming for all lifecycle transitions
(START, INPUT, MODEL_CALL, TOOL_CALL, ERROR, RETRY, COMPLETE).
- **Metrics + Cost** — Token counting, cost tracking, and performance metrics.
- **Replay Engine** — Deterministic replay from the last stable checkpoint,
with state hash verification.
### Data Plane
- **Run Ledger** — Persistent record of all runs with state, metrics, and
failure classifications.
- **Checkpoints** — Point-in-time snapshots of run state, memory, and
artifacts for replay and recovery.
- **Artifacts Store** — Tool outputs, generated files, and intermediate
results.
---
## 4. Core Data Contracts
### RunRequest
```text
RunRequest {
run_id: UUID,
workflow_ref: string,
inputs: object,
profile: {safe_mode, budget, timeout, approval_policy},
context: {user_id, workspace_id, env}
}
```
### RunEvent
```text
RunEvent {
ts: ISO8601,
run_id: UUID,
step_id: string,
type: START | INPUT | MODEL_CALL | TOOL_CALL |
ERROR | RETRY | COMPLETE,
payload: object,
cost: {tokens_in, tokens_out, usd},
latency_ms: number
}
```
### Checkpoint
```text
Checkpoint {
run_id: UUID,
step_id: string,
state_hash: string,
memory_snapshot_ref: string,
artifact_refs: string[]
}
```
### Execution Sequence
```text
1) Client submits RunRequest
2) Gateway validates profile + schema
3) Compiler resolves workflow → CompiledGraph
4) Orchestrator starts node execution
5) Node may call Model Runtime or Tool Runtime
6) Every transition emits RunEvent
7) On failure: classifier decides retry/fallback/abort
8) Checkpoint stored after each critical step
9) Final state + artifacts persisted to run ledger
10) Replay can resume from last stable checkpoint
```
---
## 5. Reliability by Design
### Error Taxonomy
All failures are classified into one of five categories for automatic
remediation:
| Category | Description | Default Action |
|----------|-------------|----------------|
| `config` | Invalid configuration or profile | Abort with remediation hint |
| `dependency` | Missing optional module or capability | Degrade / skip optional path |
| `tool` | Tool execution failure | Retry with backoff and policy cap |
| `model` | Model API error or timeout | Fallback to alternate provider/route |
| `infra` | Network or resource exhaustion | Circuit-breaker + replay from checkpoint |
```mermaid
flowchart TD
S[Step Failure] --> T{Failure Type?}
T -->|Config| C1[Abort + config remediation hint]
T -->|Dependency| C2[Degrade capability / skip optional path]
T -->|Tool| C3[Retry with backoff and policy cap]
T -->|Model| C4[Fallback model/provider route]
T -->|Infra| C5[Circuit-breaker + replay from checkpoint]
```
### Hardening Checklist
- **Dependency gates** — Optional modules declared as capabilities;
unavailable capability results in skip/degrade, not crash.
- **Cross-platform adapters** — OS-specific implementations behind a single
interface (e.g., file lock adapter for Windows/Linux/macOS).
- **Idempotent fixtures** — Integration tests use unique tenant/workspace IDs
and guaranteed teardown.
- **Fail-safe output mode** — CLI rendering falls back to ASCII-safe mode on
encoding mismatch.
- **CI parity matrix** — Run smoke workflows across Windows, Linux, and macOS.
### Deterministic Test Pattern
```python
# Integration test pattern — always use unique, traceable IDs
import uuid
def test_agent_workflow():
tenant_id = f"test-{uuid.uuid4().hex[:8]}"
agent = Agent(name=f"agent-{tenant_id}", ...)
result = agent.run("task")
assert result.status == "success"
# teardown is guaranteed via fixture or context manager
```
---
## 6. Observability & Telemetry
### Current Telemetry Stack
PraisonAI already includes:
- **OpenTelemetry integration** — Manual and auto-instrumentation for
traces, metrics, and logs.
- **Token tracking** — Per-call and cumulative token/cost tracking.
- **Performance monitoring** — Real-time dashboards and monitoring views.
- **LangTrace integration** — LangTrace provider for tracing.
- **Run outcomes** — Structured `RunOutcome` objects with status, duration,
token usage, and error classification.
### Planned Enhancements
- **Run Event Bus** — Stream all lifecycle events as structured `RunEvent`
payloads.
- **Failure classifier** — Automatic failure category detection with
remediation hints.
- **Run ledger visualization** — Timeline view of runs with step-by-step
event drill-down.
- **Replay integration** — Checkpoint-based run replay from the
observability dashboard.
---
## 7. Replay & Checkpointing
### Design
```mermaid
sequenceDiagram
participant U as User/Client
participant G as Gateway
participant C as Compiler
participant O as Orchestrator
participant R as Model/Tool Runtime
participant E as Event Bus
participant L as Run Ledger
participant K as Checkpoint Store
U->>G: Submit RunRequest
G->>C: Validate + compile workflow
C-->>O: CompiledGraph
O->>R: Execute next step
R-->>O: Step output / error
O->>E: Emit structured RunEvent
O->>K: Persist checkpoint
O->>L: Persist run state + metrics
O-->>U: Final result / failure summary
```
### Current State
- Basic run outcome persistence exists
- Session persistence for bot/multi-turn conversations
- Token and cost tracking per run
### Roadmap
- Full checkpoint creation at configurable step granularity
- State hash verification for deterministic replay
- Replay API endpoint (`POST /runs/{id}/replay`)
- Checkpoint pruning and retention policy
---
## 8. Implementation Roadmap
### Quarter 1: Trust Foundation (Q3 2026)
| Priority | Project | Description | Status |
|----------|---------|-------------|--------|
| P0 | Reliability Core | Cross-platform hardening, optional-dep gating, deterministic fixtures | In progress |
| P0 | Doctor Auto-Fix | Automated environment diagnosis and repairs | Planned |
| P1 | Golden-Path CLI | Single canonical flow: init → run → test → deploy | Planned |
| P1 | CI Parity Matrix | Cross-platform smoke tests in CI pipeline | Planned |
### Quarter 2: Operational Excellence (Q4 2026)
| Priority | Project | Description | Status |
|----------|---------|-------------|--------|
| P1 | Trace + Replay | Run timeline, checkpoint replay, failure classifier | Planned |
| P1 | Failure Classifier | Automatic failure category detection with remediation | Planned |
| P2 | Graph Studio UX | Visual deterministic orchestration editor/inspector | Planned |
| P2 | Safe Production Profiles | Policy presets: guardrails, approvals, cost/time caps | Planned |
```mermaid
gantt
title PraisonAI Architecture Program (2 Quarters)
dateFormat YYYY-MM-DD
section Quarter 1
Reliability hardening + parity CI :a1, 2026-07-01, 45d
Deterministic test isolation :a2, 2026-07-10, 50d
Doctor Auto-Fix MVP :a3, 2026-07-20, 40d
Golden-path CLI :a4, 2026-08-01, 45d
section Quarter 2
Trace bus + run ledger :b1, 2026-10-01, 45d
Replay engine :b2, 2026-10-15, 45d
Failure classifier :b3, 2026-11-01, 30d
Graph UX beta + Safe profiles :b4, 2026-11-10, 50d
```
---
## 9. Success Metrics
### KPI Targets
| KPI | Target | Primary Workstream |
|-----|--------|-------------------|
| Time-to-first-successful run | 30-40% reduction | Golden-path CLI + Doctor Auto-Fix |
| Cross-platform issue rate | 50% reduction | Reliability Core + adapter layer |
| Incident triage time | 40% reduction | Trace + Replay + failure classifier |
| CI confidence | Flaky test rate <2% | Deterministic tests + fixture isolation |
### Governance
- **Architecture Review Council** — Reviews runtime-contract changes
- **Release Quality Gate** — Platform matrix + deterministic test thresholds
- **Monthly telemetry review** — DX, reliability, adoption trends
---
*This document is a living reference. Update as the architecture evolves.*
+2 -2
View File
@@ -770,11 +770,11 @@ agent.start('Write a movie script about a robot in Mars');
## ⚡ Performance
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
| Performance Metric | PraisonAI |
|--------------------|-----------|
| Avg Instantiation Time | **14 μs** |
| Avg Instantiation Time | **3.77 μs** |
---
+13 -79
View File
@@ -30,7 +30,6 @@ from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepR
Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
@@ -60,9 +59,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">list_mcp_servers</a>() -> List[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
@@ -72,8 +68,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -246,7 +240,6 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
@@ -483,16 +476,11 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <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">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</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</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</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, agent_created: bool = True, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</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</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</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_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
@@ -501,24 +489,15 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</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">list_archived_skills</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</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</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</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, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> 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, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</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">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</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, propose: Optional[bool] = None) -> dict</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">idle_days</a>() -> Optional[float]</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>
@@ -536,12 +515,10 @@ Methods:
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">add_memory</a>(text: str, memory_type: str = 'long', **metadata) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">chat</a>(message: str, **kwargs) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">clear_memory</a>(memory_type: str = 'all') -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">close</a>() -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">create_agent</a>(*args, **kwargs) -> 'Agent'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_context</a>(query: str, max_items: int = 3) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_state</a>(key: str, default: Any = None) -> Any</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">increment_state</a>(key: str, increment: int = 1, default: int = 0) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">is_expired</a>() -> bool</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">knowledge</a>() -> 'Knowledge'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">memory</a>() -> 'Memory'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">restore_state</a>() -> Dict[str, Any]</code>
@@ -550,7 +527,6 @@ Methods:
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">search_memory</a>(query: str, memory_type: str = 'long', limit: int = 5) -> List[Dict[str, Any]]</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">send_message</a>(message: str, **kwargs) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">time_to_expiry</a>() -> Optional[float]</code>
# MCP
@@ -633,7 +609,7 @@ Methods:
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">register_snapshot_callback</a>(callback: Callable[[SnapshotHookData], None]) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">reset</a>() -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str, tool_call_id: str = None, run_id: str = None) -> str</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
# UI
@@ -722,7 +698,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -813,17 +789,11 @@ Methods:
* <code title="cli">praisonai <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai acp acp-main <a href="./src/praisonai/praisonai/cli/commands/acp.py">--help</a></code>
* <code title="cli">praisonai agent list <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agent show <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agents create <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth logout <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth status <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch report <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
@@ -864,8 +834,6 @@ Methods:
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
* <code title="cli">praisonai command list <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai command show <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
* <code title="cli">praisonai completion bash <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai completion completion-callback <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
@@ -878,9 +846,6 @@ Methods:
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config reset <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config set <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config show <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config sources <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config validate <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai context add <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
@@ -911,18 +876,17 @@ Methods:
* <code title="cli">praisonai docs serve <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs stats <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai doctor cleanup <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor docker <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor config <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor doctor-callback <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor llm-providers <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor memory-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor metadata-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor runtime <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai endpoints test <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
@@ -950,9 +914,6 @@ Methods:
* <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 pause <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway reconnect <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway resume <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>
@@ -962,11 +923,6 @@ Methods:
* <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>
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai identity import <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity link <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity list <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity unlink <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai init init <a href="./src/praisonai/praisonai/cli/commands/init.py">--help</a></code>
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
@@ -1031,9 +987,6 @@ 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 models describe <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models list <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models validate <a href="./src/praisonai/praisonai/cli/commands/models.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>
@@ -1051,14 +1004,6 @@ Methods:
* <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 permissions allow <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions ask <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions deny <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions export <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions import-rules <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions list <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions remove <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions reset <a href="./src/praisonai/praisonai/cli/commands/permissions.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>
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
@@ -1121,8 +1066,6 @@ Methods:
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint-list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -1132,10 +1075,6 @@ Methods:
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stop <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-accept <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai serve a2a <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve a2u <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve acp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
@@ -1144,7 +1083,6 @@ Methods:
* <code title="cli">praisonai serve gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve lsp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve mcp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve openai <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve rag <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve recipe <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve registry <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
@@ -1171,7 +1109,6 @@ Methods:
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills learn <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills search <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
@@ -1213,9 +1150,6 @@ Methods:
* <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 validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.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>
@@ -1368,7 +1302,7 @@ export { // Python parity additions
export { // Subagent Tool (agent-as-tool pattern)
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
export { registerBuiltinTools, tools } from "./tools/tools";
export { // Classes
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.77" \
"praisonai>=4.6.55" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.77" \
"praisonai>=4.6.55" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.77" \
"praisonai>=4.6.55" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -5,9 +5,9 @@ Demonstrates web search with consolidated params.
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
"""
from praisonaiagents import Agent, WebConfig
from praisonaiagents import Agent
# Basic: Enable web search with preset (search only — fetch needs Claude)
# Basic: Enable web search with preset
agent = Agent(
instructions="You are a research assistant with web access.",
web="search_only", # Compatible across models without web_fetch
@@ -1,216 +0,0 @@
# Industry Templates for PraisonAI
This directory contains industry-specific agent templates based on the SRAO Framework, providing pre-built workflows for rapid deployment of agent workforces across different industries.
## Overview
These templates demonstrate how PraisonAI can be used to create specialized agent teams for various industries, with **70% code reuse** across different sectors through shared patterns and abstractions.
## Available Templates
### 1. Manufacturing (`manufacturing_template.py`)
**Key Agents:**
- **ParseOrder**: Extracts and structures order information from various formats
- **CheckInventory**: Validates material availability and supply chain status
- **OptimizeSchedule**: Optimizes production scheduling based on constraints
- **DefectDetect**: Quality control and defect detection
**Features:**
- Order processing with multiple input formats
- Real-time inventory management
- Production optimization algorithms
- Quality inspection with defect detection
- Fallback strategies for each critical step
### 2. Energy (`energy_template.py`)
**Key Agents:**
- **SCADAReader**: Processes SCADA telemetry data from wind turbines
- **VibrationAnalyzer**: Analyzes vibration patterns for predictive maintenance
- **PowerForecaster**: Predicts power generation based on weather and history
- **MaintenanceScheduler**: Schedules predictive maintenance
**Features:**
- Real-time wind farm monitoring
- Vibration-based fault detection
- Power generation forecasting
- Grid integration and load balancing
- Predictive maintenance scheduling
### 3. Healthcare (`healthcare_template.py`)
**Key Agents:**
- **VitalSignsCapture**: Processes patient vital signs from monitoring devices
- **EMRRetrieval**: Retrieves electronic medical records with HIPAA compliance
- **TriageRecommendation**: Provides ESI-based triage recommendations
- **ResourceAllocator**: Manages hospital resource allocation
**Features:**
- Emergency triage workflow
- HIPAA-compliant data handling
- Patient safety protocols
- Resource optimization
- Cross-department coordination
### 4. Agriculture (`agriculture_template.py`)
**Key Agents:**
- **MultispectralAnalyzer**: Analyzes multispectral imagery for crop health
- **DiseaseIdentifier**: Identifies crop diseases and pests using AI
- **SprayRecommender**: Recommends targeted spraying strategies
- **YieldPredictor**: Predicts crop yield based on conditions
**Features:**
- Precision agriculture with drone/satellite imagery
- Pest and disease early warning system
- Optimized chemical application
- Yield forecasting
- Sustainable farming patterns
### 5. Transportation (`transportation_template.py`)
**Key Agents:**
- **LiDARFusion**: Processes LiDAR point cloud data for infrastructure
- **DisplacementCalculator**: Calculates structural displacement
- **HeatmapGenerator**: Generates safety visualization heatmaps
- **MaintenancePlanner**: Plans infrastructure maintenance
**Features:**
- Tunnel/bridge safety monitoring
- LiDAR-based structural analysis
- Real-time safety assessment
- Traffic flow optimization
- Predictive maintenance scheduling
## Key Features Across All Templates
### 1. I/O Schemas
Each template includes Pydantic models for structured data handling:
- Input validation
- Type safety
- Clear data contracts between agents
### 2. SLA Requirements
Every agent has defined Service Level Agreements:
- Processing time requirements
- Response time guarantees
- Performance benchmarks
### 3. Fallback Strategies
Robust error handling with fallback mechanisms:
- Graceful degradation
- Manual intervention queues
- Conservative estimates when systems fail
### 4. Cross-Industry Patterns (70% Reuse)
Reusable base patterns that work across industries:
- Data parsing agents
- Resource availability checkers
- Optimization engines
- Inspection/quality control agents
## Usage Example
```python
from manufacturing_template import (
order_parser,
inventory_checker,
schedule_optimizer,
quality_inspector,
manufacturing_workflow
)
# Process a manufacturing order
order_text = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
result = manufacturing_workflow(order_text)
print(result)
```
## Adapting Templates for Your Industry
### Using Base Patterns
Each template includes reusable pattern classes that can be adapted:
```python
from manufacturing_template import IndustryAgentPattern
# Create a custom data parser for any domain
custom_parser = IndustryAgentPattern.create_data_parser(
name="CustomParser",
domain="retail",
sla_seconds=20
)
# Create a resource checker for any resource type
resource_checker = IndustryAgentPattern.create_resource_checker(
name="StaffAvailability",
resource_type="customer_service_staff",
sla_seconds=3
)
```
### Creating Custom Workflows
Combine agents from different templates or create new ones:
```python
from praisonaiagents import Agent, tool
# Define custom tools
@tool
def custom_analysis(data: str) -> Dict:
"""Your custom analysis logic"""
return {"result": "analyzed"}
# Create custom agent
custom_agent = Agent(
name="CustomAnalyzer",
instructions="Your specific instructions",
tools=[custom_analysis]
)
```
## Integration with PraisonAI Core
These templates are built on top of PraisonAI's core Agent framework:
```python
from praisonaiagents import Agent, tool, AgentTeam, Task
# Combine multiple industry agents into a team
team = AgentTeam(
agents=[order_parser, inventory_checker, schedule_optimizer],
tasks=[
Task(name="parse", agent=order_parser),
Task(name="check", agent=inventory_checker),
Task(name="optimize", agent=schedule_optimizer)
]
)
```
## Best Practices
1. **Start with Templates**: Use these as starting points and customize for your specific needs
2. **Maintain SLAs**: Define clear performance requirements for each agent
3. **Implement Fallbacks**: Always have fallback strategies for critical operations
4. **Use Type Safety**: Leverage Pydantic models for data validation
5. **Monitor Performance**: Track agent performance against SLAs
6. **Iterate and Improve**: Continuously refine based on real-world usage
## Contributing
To add a new industry template:
1. Follow the existing template structure
2. Include all key components:
- I/O Schemas (Pydantic models)
- Agent definitions with SLAs
- Tools for agent capabilities
- Complete workflow function
- Fallback strategies
- Cross-industry patterns
3. Add comprehensive documentation
4. Include usage examples
## License
These templates are part of the PraisonAI project and follow the same licensing terms.
## References
Based on the [SRAO Framework](https://github.com/beixuan577/SRAO-Framework) (MIT License)
@@ -1,38 +0,0 @@
"""
Industry Templates for PraisonAI
================================
Pre-built agent templates for rapid deployment across different industries.
Based on SRAO Framework with 70% cross-industry code reuse.
Available templates:
- Manufacturing: Order processing, inventory, scheduling, quality control
- Energy: Wind farm monitoring, predictive maintenance, power forecasting
- Healthcare: Emergency triage, EMR retrieval, resource allocation
- Agriculture: Precision farming, pest detection, yield prediction
- Transportation: Infrastructure monitoring, safety assessment, maintenance
Example usage:
from Industry_Templates.manufacturing_template import manufacturing_workflow
result = manufacturing_workflow("Customer order text")
"""
__version__ = "1.0.0"
__author__ = "PraisonAI Team"
# Import the modules so they're accessible via the package
from . import manufacturing_template
from . import energy_template
from . import healthcare_template
from . import agriculture_template
from . import transportation_template
# Export commonly used items for convenience
__all__ = [
"manufacturing_template",
"energy_template",
"healthcare_template",
"agriculture_template",
"transportation_template"
]
@@ -1,551 +0,0 @@
"""
Agriculture Industry Template
=============================
Precision agriculture and pest management workflow
Based on SRAO Framework with IoT sensor integration
Key agents:
- MultispectralAnalyzer: Analyzes multispectral imagery
- DiseaseIdentifier: Identifies crop diseases and pests
- SprayRecommender: Recommends targeted spraying strategies
- YieldPredictor: Predicts crop yield based on conditions
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Tuple, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
from enum import Enum
# Crop health levels
class CropHealthLevel(str, Enum):
EXCELLENT = "excellent"
GOOD = "good"
FAIR = "fair"
POOR = "poor"
CRITICAL = "critical"
# Pest/Disease severity
class SeverityLevel(str, Enum):
NONE = "none"
LOW = "low"
MODERATE = "moderate"
HIGH = "high"
SEVERE = "severe"
# I/O Schemas
class MultispectralData(BaseModel):
"""Multispectral imagery analysis data"""
field_id: str
capture_time: str
ndvi_index: float # Normalized Difference Vegetation Index
ndre_index: float # Normalized Difference Red Edge
ccci_index: float # Canopy Chlorophyll Content Index
moisture_level: float # Soil moisture percentage
temperature: float # Celsius
affected_area: float # Hectares
gps_coordinates: Tuple[float, float]
class PestDiseaseReport(BaseModel):
"""Pest and disease identification report"""
report_id: str
field_id: str
detection_time: str
pest_type: Optional[str]
disease_type: Optional[str]
severity: SeverityLevel
affected_crops: List[str]
spread_rate: float # % per day
environmental_factors: Dict[str, Any]
confidence_score: float
class SprayRecommendation(BaseModel):
"""Targeted spraying recommendation"""
recommendation_id: str
field_id: str
treatment_type: str # pesticide, fungicide, herbicide, fertilizer
product_name: str
application_rate: float # L/hectare
target_zones: List[Dict[str, Any]] # GPS zones
optimal_time: str
weather_window: Dict[str, Any]
cost_estimate: float
environmental_impact: str # low, medium, high
class YieldForecast(BaseModel):
"""Crop yield prediction"""
forecast_id: str
field_id: str
crop_type: str
predicted_yield: float # tons/hectare
confidence_interval: Tuple[float, float]
harvest_window: Tuple[str, str]
quality_grade: str # A, B, C
market_price_estimate: float
risk_factors: List[str]
# Agriculture-specific tools
@tool
def analyze_multispectral_imagery(field_id: str, image_data: Dict) -> Dict:
"""Analyze multispectral drone/satellite imagery for crop health"""
# Simulate multispectral analysis
ndvi = 0.75 # Healthy vegetation typically 0.6-0.9
# Determine health based on NDVI
if ndvi > 0.8:
health = CropHealthLevel.EXCELLENT
affected_area = 0.0
elif ndvi > 0.6:
health = CropHealthLevel.GOOD
affected_area = 2.5
elif ndvi > 0.4:
health = CropHealthLevel.FAIR
affected_area = 5.0
else:
health = CropHealthLevel.POOR
affected_area = 10.0
return {
"field_id": field_id,
"capture_time": datetime.now().isoformat(),
"ndvi_index": ndvi,
"ndre_index": 0.68,
"ccci_index": 0.72,
"moisture_level": 35.0,
"temperature": 24.5,
"affected_area": affected_area,
"gps_coordinates": (40.7128, -74.0060),
"health_level": health.value
}
@tool
def identify_pest_disease(spectral_data: Dict, historical_data: List) -> Dict:
"""Identify pests and diseases using AI image recognition"""
# Simulate pest/disease detection
ndvi = spectral_data.get("ndvi_index", 0.75)
affected_area = spectral_data.get("affected_area", 0)
if affected_area > 8.0:
pest_type = "aphids"
disease_type = "powdery_mildew"
severity = SeverityLevel.HIGH
spread_rate = 2.5
elif affected_area > 4.0:
pest_type = "whiteflies"
disease_type = "leaf_spot"
severity = SeverityLevel.MODERATE
spread_rate = 1.5
elif affected_area > 0:
pest_type = None
disease_type = "early_blight"
severity = SeverityLevel.LOW
spread_rate = 0.5
else:
pest_type = None
disease_type = None
severity = SeverityLevel.NONE
spread_rate = 0.0
return {
"report_id": f"PDR-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": spectral_data["field_id"],
"detection_time": datetime.now().isoformat(),
"pest_type": pest_type,
"disease_type": disease_type,
"severity": severity.value,
"affected_crops": ["wheat", "corn"],
"spread_rate": spread_rate,
"environmental_factors": {
"humidity": 65,
"temperature": spectral_data.get("temperature", 25),
"rainfall_last_week": 15 # mm
},
"confidence_score": 0.85
}
@tool
def calculate_spray_recommendation(pest_report: Dict, field_data: Dict, weather: Dict) -> Dict:
"""Calculate optimal spraying strategy for pest/disease control"""
severity = pest_report.get("severity", "none")
if severity == "high" or severity == "severe":
treatment = "pesticide"
product = "Imidacloprid"
rate = 0.5
impact = "medium"
elif severity == "moderate":
treatment = "fungicide"
product = "Azoxystrobin"
rate = 0.3
impact = "low"
elif severity == "low":
treatment = "organic_treatment"
product = "Neem Oil"
rate = 0.2
impact = "minimal"
else:
treatment = "fertilizer"
product = "NPK 20-20-20"
rate = 0.4
impact = "positive"
# Calculate target zones based on affected area
affected_area = field_data.get("affected_area", 0)
if affected_area > 0:
target_zones = [
{
"zone_id": "A1",
"gps_boundary": [(40.71, -74.00), (40.72, -74.01)],
"priority": "high"
}
]
else:
target_zones = []
return {
"recommendation_id": f"SPR-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": field_data["field_id"],
"treatment_type": treatment,
"product_name": product,
"application_rate": rate,
"affected_area": affected_area,
"target_zones": target_zones,
"optimal_time": (datetime.now() + timedelta(days=1)).isoformat(),
"weather_window": {
"wind_speed": weather.get("wind_speed", 5),
"precipitation": weather.get("precipitation", 0),
"suitable": True
},
"cost_estimate": rate * affected_area * 50, # $/hectare
"environmental_impact": impact
}
@tool
def predict_crop_yield(field_id: str, crop_type: str, current_health: Dict, weather_forecast: Dict) -> Dict:
"""Predict crop yield based on current conditions and forecasts"""
# Simulate yield prediction
health_level = current_health.get("health_level", "good")
base_yield = {
"wheat": 3.5,
"corn": 9.5,
"soybeans": 2.8,
"rice": 6.0
}.get(crop_type, 5.0)
# Adjust based on health
health_multiplier = {
"excellent": 1.2,
"good": 1.0,
"fair": 0.8,
"poor": 0.6,
"critical": 0.4
}.get(health_level, 1.0)
predicted_yield = base_yield * health_multiplier
# Quality grade based on conditions
if health_level in ["excellent", "good"]:
quality = "A"
price_multiplier = 1.2
elif health_level == "fair":
quality = "B"
price_multiplier = 1.0
else:
quality = "C"
price_multiplier = 0.8
return {
"forecast_id": f"YLD-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": field_id,
"crop_type": crop_type,
"predicted_yield": predicted_yield,
"confidence_interval": (predicted_yield * 0.9, predicted_yield * 1.1),
"harvest_window": (
(datetime.now() + timedelta(days=60)).isoformat(),
(datetime.now() + timedelta(days=75)).isoformat()
),
"quality_grade": quality,
"market_price_estimate": predicted_yield * 250 * price_multiplier, # $/ton
"risk_factors": ["weather_variability", "pest_pressure", "market_volatility"]
}
# Agriculture agent definitions
multispectral_agent = Agent(
name="MultispectralAnalyzer",
instructions="""You are a remote sensing specialist for precision agriculture.
Analyze multispectral imagery from drones and satellites.
Calculate vegetation indices (NDVI, NDRE, CCCI) to assess crop health.
Identify stress zones and anomalies in field conditions.
SLA: Process imagery within 2 minutes per field.""",
tools=[analyze_multispectral_imagery]
)
disease_agent = Agent(
name="DiseaseIdentifier",
instructions="""You are a plant pathology expert with AI vision capabilities.
Identify crop diseases, pests, and nutrient deficiencies.
Assess severity levels and predict spread patterns.
Consider environmental factors affecting disease development.
SLA: Complete identification within 30 seconds.""",
tools=[identify_pest_disease]
)
spray_agent = Agent(
name="SprayRecommender",
instructions="""You are a precision application specialist.
Recommend targeted spraying strategies for pest and disease control.
Optimize chemical usage to minimize environmental impact.
Consider weather windows and application regulations.
SLA: Generate recommendations within 1 minute.""",
tools=[calculate_spray_recommendation]
)
yield_agent = Agent(
name="YieldPredictor",
instructions="""You are a crop yield forecasting specialist.
Predict harvest yields based on current conditions and historical data.
Assess crop quality grades and market value estimates.
Identify risk factors that could impact final yield.
SLA: Generate forecast within 45 seconds.""",
tools=[predict_crop_yield]
)
# Precision agriculture workflow
def precision_agriculture_workflow(field_ids: List[str], crop_type: str, weather_data: Dict):
"""
Complete precision agriculture workflow from monitoring to treatment
Includes early warning system and sustainable farming practices
"""
workflow_results = {
"timestamp": datetime.now().isoformat(),
"fields_monitored": len(field_ids),
"alerts": [],
"treatments_needed": [],
"yield_forecasts": [],
"sustainability_score": 0.0
}
for field_id in field_ids:
try:
# Step 1: Multispectral analysis
spectral_analysis = multispectral_agent.start(
f"Analyze multispectral imagery for field {field_id}"
)
# Step 2: Disease and pest detection
pest_report = disease_agent.start(
f"Identify pests/diseases from spectral data: {spectral_analysis}"
)
# Check for alerts
if pest_report.get("severity") in ["high", "severe"]:
workflow_results["alerts"].append({
"field_id": field_id,
"type": "pest_disease_alert",
"severity": pest_report["severity"],
"pest": pest_report.get("pest_type"),
"disease": pest_report.get("disease_type"),
"action": "immediate_treatment_required"
})
# Step 3: Generate spray recommendation
spray_recommendation = spray_agent.start(
f"Calculate spray strategy for pest report: {pest_report}, "
f"field: {spectral_analysis}, weather: {weather_data}"
)
workflow_results["treatments_needed"].append(spray_recommendation)
# Step 4: Yield prediction
yield_forecast = yield_agent.start(
f"Predict yield for field {field_id}, crop {crop_type}, "
f"health: {spectral_analysis}, weather: {weather_data}"
)
workflow_results["yield_forecasts"].append(yield_forecast)
except Exception as e:
# Fallback: Schedule manual inspection
workflow_results["alerts"].append({
"field_id": field_id,
"type": "monitoring_failure",
"error": str(e),
"action": "schedule_manual_inspection"
})
# Calculate sustainability score
total_chemical = sum(
t.get("application_rate", 0) * t.get("affected_area", 0)
for t in workflow_results["treatments_needed"]
)
workflow_results["sustainability_score"] = max(0, 100 - (total_chemical * 10))
return workflow_results
# Sustainable farming patterns
class SustainableFarmingPatterns:
"""
Patterns for sustainable and regenerative agriculture
"""
@staticmethod
def integrated_pest_management(pest_level: str) -> Dict:
"""IPM strategy based on pest pressure"""
strategies = {
"low": {
"method": "biological_control",
"actions": ["release_beneficial_insects", "plant_trap_crops"],
"chemical_use": 0
},
"moderate": {
"method": "targeted_intervention",
"actions": ["spot_treatment", "pheromone_traps", "minimal_spray"],
"chemical_use": 30
},
"high": {
"method": "integrated_approach",
"actions": ["systematic_spray", "crop_rotation", "resistant_varieties"],
"chemical_use": 70
}
}
return strategies.get(pest_level, strategies["moderate"])
@staticmethod
def precision_irrigation(moisture_level: float, crop_stage: str) -> Dict:
"""Calculate optimal irrigation based on soil moisture and crop stage"""
# Water requirements by growth stage (mm/day)
stage_requirements = {
"germination": 3.0,
"vegetative": 5.0,
"flowering": 7.0,
"grain_filling": 6.0,
"maturation": 2.0
}
required = stage_requirements.get(crop_stage, 5.0)
deficit = max(0, 40 - moisture_level) # Target 40% moisture
return {
"irrigation_needed": deficit > 10,
"amount_mm": deficit * 2,
"method": "drip" if deficit < 20 else "sprinkler",
"schedule": "night_irrigation" if deficit > 15 else "morning_irrigation",
"water_saved": (100 - deficit) / 100 * 1000 # Liters per hectare
}
@staticmethod
def crop_rotation_optimizer(current_crop: str, soil_data: Dict) -> str:
"""Recommend next crop for rotation based on soil health"""
rotations = {
"wheat": ["soybeans", "canola"], # Nitrogen fixation
"corn": ["soybeans", "alfalfa"], # Break disease cycle
"soybeans": ["wheat", "corn"], # Utilize fixed nitrogen
"rice": ["legumes", "vegetables"], # Soil structure improvement
}
return rotations.get(current_crop, ["cover_crops"])[0]
# IoT sensor integration patterns
class IoTSensorPatterns:
"""
Patterns for integrating IoT sensors in smart farming
"""
@staticmethod
def create_sensor_network_agent(sensor_types: List[str]) -> Agent:
"""Create agent for managing IoT sensor networks"""
return Agent(
name="SensorNetworkManager",
instructions=f"""Manage IoT sensor network with {', '.join(sensor_types)}.
Collect real-time data from field sensors.
Detect sensor anomalies and maintenance needs.
Aggregate data for precision agriculture decisions.
SLA: Data collection every 15 minutes."""
)
@staticmethod
def process_sensor_data(sensor_readings: List[Dict]) -> Dict:
"""Process and aggregate IoT sensor data"""
aggregated = {
"timestamp": datetime.now().isoformat(),
"sensor_count": len(sensor_readings),
"averages": {},
"alerts": []
}
# Calculate averages
for reading in sensor_readings:
for key, value in reading.items():
if isinstance(value, (int, float)):
if key not in aggregated["averages"]:
aggregated["averages"][key] = []
aggregated["averages"][key].append(value)
# Convert to means
for key in aggregated["averages"]:
values = aggregated["averages"][key]
aggregated["averages"][key] = sum(values) / len(values)
# Check for anomalies
if key == "soil_ph" and (aggregated["averages"][key] < 6.0 or aggregated["averages"][key] > 7.5):
aggregated["alerts"].append({
"type": "soil_ph_anomaly",
"value": aggregated["averages"][key],
"action": "adjust_soil_treatment"
})
return aggregated
# Example usage
if __name__ == "__main__":
# Monitor multiple fields
fields = ["FIELD-001", "FIELD-002", "FIELD-003"]
weather = {
"temperature": 25,
"humidity": 60,
"wind_speed": 8,
"precipitation": 0,
"forecast": "partly_cloudy"
}
result = precision_agriculture_workflow(fields, "wheat", weather)
print("Agriculture workflow result:", result)
# Sustainable farming example
ipm_strategy = SustainableFarmingPatterns.integrated_pest_management("moderate")
print("IPM Strategy:", ipm_strategy)
irrigation_plan = SustainableFarmingPatterns.precision_irrigation(25.0, "flowering")
print("Irrigation Plan:", irrigation_plan)
next_crop = SustainableFarmingPatterns.crop_rotation_optimizer("wheat", {})
print("Recommended next crop:", next_crop)
# IoT integration example
sensor_agent = IoTSensorPatterns.create_sensor_network_agent(
["soil_moisture", "temperature", "humidity", "light", "ph"]
)
sensor_data = [
{"sensor_id": "S001", "soil_moisture": 35, "temperature": 24, "soil_ph": 6.8},
{"sensor_id": "S002", "soil_moisture": 32, "temperature": 25, "soil_ph": 7.2},
{"sensor_id": "S003", "soil_moisture": 38, "temperature": 23, "soil_ph": 5.8}
]
processed_data = IoTSensorPatterns.process_sensor_data(sensor_data)
print("Processed sensor data:", processed_data)
@@ -1,372 +0,0 @@
"""
Energy Industry Template
========================
Wind farm monitoring and power optimization workflow
Based on SRAO Framework patterns with predictive maintenance
Key agents:
- SCADAReader: Processes SCADA telemetry data
- VibrationAnalyzer: Analyzes turbine vibration patterns
- PowerForecaster: Predicts power generation
- MaintenanceScheduler: Schedules predictive maintenance
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
# I/O Schemas
class TurbineData(BaseModel):
"""Wind turbine telemetry data"""
turbine_id: str
timestamp: str
wind_speed: float # m/s
power_output: float # MW
rotor_speed: float # RPM
blade_pitch: float # degrees
nacelle_temperature: float # Celsius
vibration_level: float # mm/s
operational_status: str # running, stopped, maintenance
class VibrationAnalysis(BaseModel):
"""Vibration analysis results"""
turbine_id: str
analysis_time: str
vibration_rms: float
frequency_peaks: List[float]
anomaly_detected: bool
failure_probability: float
recommended_action: str
class PowerForecast(BaseModel):
"""Power generation forecast"""
forecast_id: str
start_time: str
end_time: str
predicted_output: List[float] # MW per hour
confidence_interval: float
weather_factors: Dict[str, Any]
grid_demand: float
class MaintenancePlan(BaseModel):
"""Maintenance scheduling plan"""
plan_id: str
turbine_id: str
maintenance_type: str # preventive, predictive, corrective
scheduled_date: str
estimated_duration: int # hours
required_parts: List[str]
technician_team: str
production_loss: float # MW
# Tools for energy management
@tool
def read_scada_telemetry(turbine_id: str, time_range: str = "last_hour") -> Dict:
"""Read SCADA system telemetry for wind turbines"""
# Simulate SCADA data reading
return {
"turbine_id": turbine_id,
"timestamp": datetime.now().isoformat(),
"wind_speed": 12.5,
"power_output": 2.3,
"rotor_speed": 16.8,
"blade_pitch": 8.5,
"nacelle_temperature": 45.2,
"vibration_level": 3.2,
"operational_status": "running"
}
@tool
def analyze_vibration_patterns(turbine_data: Dict) -> Dict:
"""Analyze vibration patterns for early fault detection"""
# Simulate vibration analysis
vibration_level = turbine_data.get("vibration_level", 0)
anomaly = vibration_level > 5.0 # Threshold for anomaly
return {
"turbine_id": turbine_data["turbine_id"],
"analysis_time": datetime.now().isoformat(),
"vibration_rms": vibration_level,
"frequency_peaks": [10.5, 25.3, 50.0], # Hz
"anomaly_detected": anomaly,
"failure_probability": min(vibration_level / 10.0, 1.0),
"recommended_action": "immediate_inspection" if anomaly else "continue_monitoring"
}
@tool
def forecast_power_generation(weather_data: Dict, historical_data: List) -> Dict:
"""Forecast power generation based on weather and historical patterns"""
# Simulate power forecasting
base_forecast = [2.0 + (i * 0.1) for i in range(24)] # 24-hour forecast
return {
"forecast_id": f"FCST-{datetime.now().strftime('%Y%m%d%H%M')}",
"start_time": datetime.now().isoformat(),
"end_time": (datetime.now() + timedelta(hours=24)).isoformat(),
"predicted_output": base_forecast,
"confidence_interval": 0.85,
"weather_factors": weather_data,
"grid_demand": sum(base_forecast)
}
@tool
def schedule_maintenance(turbine_id: str, maintenance_type: str, urgency: str = "normal") -> Dict:
"""Schedule maintenance based on condition monitoring"""
# Calculate maintenance window
if urgency == "urgent":
scheduled_date = (datetime.now() + timedelta(days=1)).isoformat()
else:
scheduled_date = (datetime.now() + timedelta(days=7)).isoformat()
return {
"plan_id": f"MNT-{datetime.now().strftime('%Y%m%d%H%M')}",
"turbine_id": turbine_id,
"maintenance_type": maintenance_type,
"scheduled_date": scheduled_date,
"estimated_duration": 4,
"required_parts": ["bearing_set", "lubricant", "filter"],
"technician_team": "Team-A",
"production_loss": 8.0 # MW lost during maintenance
}
# Agent Definitions with SLA requirements
scada_reader = Agent(
name="SCADAReader",
instructions="""You are a SCADA system specialist for wind farms.
Process real-time telemetry data from wind turbines.
Identify operational anomalies and performance deviations.
SLA: Process telemetry within 1 second for real-time monitoring.""",
tools=[read_scada_telemetry]
)
vibration_analyzer = Agent(
name="VibrationAnalyzer",
instructions="""You are a vibration analysis expert for rotating machinery.
Analyze vibration patterns to detect early signs of mechanical failure.
Use FFT analysis and pattern recognition for fault diagnosis.
SLA: Complete analysis within 5 seconds per turbine.""",
tools=[analyze_vibration_patterns]
)
power_forecaster = Agent(
name="PowerForecaster",
instructions="""You are a power generation forecasting specialist.
Predict power output based on weather forecasts and historical patterns.
Consider grid demand and optimize generation schedules.
SLA: Generate 24-hour forecast within 30 seconds.""",
tools=[forecast_power_generation]
)
maintenance_scheduler = Agent(
name="MaintenanceScheduler",
instructions="""You are a predictive maintenance planning expert.
Schedule maintenance based on condition monitoring and failure predictions.
Minimize production loss while ensuring turbine reliability.
SLA: Create maintenance plan within 1 minute.""",
tools=[schedule_maintenance]
)
# Energy workflow with grid integration
def energy_monitoring_workflow(turbine_ids: List[str], weather_forecast: Dict):
"""
Complete energy monitoring and optimization workflow
Includes predictive maintenance and grid integration
"""
results = {
"timestamp": datetime.now().isoformat(),
"turbines_monitored": len(turbine_ids),
"alerts": [],
"maintenance_required": [],
"power_forecast": None
}
# Step 1: Monitor all turbines
for turbine_id in turbine_ids:
try:
# Read SCADA data
scada_data = scada_reader.start(
f"Read telemetry for turbine {turbine_id}"
)
# Analyze vibrations
vibration_result = vibration_analyzer.start(
f"Analyze vibrations for data: {scada_data}"
)
# Check for anomalies
if vibration_result.get("anomaly_detected"):
results["alerts"].append({
"turbine_id": turbine_id,
"type": "vibration_anomaly",
"severity": "high" if vibration_result.get("failure_probability", 0) > 0.7 else "medium"
})
# Schedule maintenance if needed
maintenance_plan = maintenance_scheduler.start(
f"Schedule maintenance for turbine {turbine_id} with urgency based on {vibration_result}"
)
results["maintenance_required"].append(maintenance_plan)
except Exception as e:
# Fallback: Mark turbine for manual inspection
results["alerts"].append({
"turbine_id": turbine_id,
"type": "monitoring_failure",
"error": str(e),
"action": "manual_inspection_required"
})
# Step 2: Power generation forecast
try:
historical_data = [] # Would be fetched from database
power_forecast = power_forecaster.start(
f"Forecast power with weather {weather_forecast} and history {historical_data}"
)
results["power_forecast"] = power_forecast
# Grid integration check
if power_forecast.get("predicted_output"):
total_predicted = sum(power_forecast["predicted_output"])
grid_demand = power_forecast.get("grid_demand", 0)
if total_predicted < grid_demand * 0.9:
results["alerts"].append({
"type": "supply_shortage",
"predicted": total_predicted,
"demand": grid_demand,
"action": "consider_backup_sources"
})
except Exception as e:
# Fallback: Use conservative forecast
results["power_forecast"] = {
"status": "fallback_mode",
"conservative_estimate": len(turbine_ids) * 1.5, # MW
"error": str(e)
}
return results
# Cross-industry pattern adaptation
class EnergyPatternAdapter:
"""
Adapts manufacturing patterns for energy sector
Demonstrates 70% code reuse across industries
"""
@staticmethod
def adapt_for_solar_farm():
"""Adapt wind farm agents for solar farm monitoring"""
return Agent(
name="SolarMonitor",
instructions="""Monitor solar panel efficiency and temperature.
Track irradiance levels and predict power output.
Detect panel degradation and soiling issues.
SLA: Real-time monitoring with 2-second updates."""
)
@staticmethod
def adapt_for_power_grid():
"""Adapt for power grid management"""
return Agent(
name="GridBalancer",
instructions="""Balance power supply and demand across the grid.
Manage load distribution and prevent blackouts.
Coordinate with renewable and traditional sources.
SLA: Sub-second response for grid stability."""
)
@staticmethod
def adapt_for_battery_storage():
"""Adapt for battery energy storage systems"""
return Agent(
name="BatteryOptimizer",
instructions="""Optimize battery charge/discharge cycles.
Predict battery degradation and schedule replacements.
Maximize energy arbitrage opportunities.
SLA: Optimization decision within 10 seconds."""
)
# Fallback strategies for critical operations
class EnergyFallbackStrategies:
"""
Fallback strategies for maintaining grid stability
"""
@staticmethod
def turbine_failure_fallback(failed_turbine_id: str, available_turbines: List[str]):
"""Handle turbine failure by redistributing load"""
return {
"strategy": "load_redistribution",
"failed_turbine": failed_turbine_id,
"compensating_turbines": available_turbines[:3], # Use top 3 turbines
"increase_output_by": 0.3, # MW per turbine
"duration": "until_maintenance_complete"
}
@staticmethod
def forecast_failure_fallback(last_known_forecast: Optional[Dict]):
"""Handle forecasting system failure"""
if last_known_forecast:
return {
"strategy": "use_last_known",
"forecast": last_known_forecast,
"confidence": 0.6,
"validity": "4_hours"
}
else:
return {
"strategy": "seasonal_average",
"estimated_output": 50.0, # MW based on seasonal average
"confidence": 0.4,
"recommendation": "increase_reserves"
}
@staticmethod
def communication_failure_fallback():
"""Handle SCADA communication failure"""
return {
"strategy": "local_control_mode",
"actions": [
"Switch turbines to autonomous mode",
"Use local safety thresholds",
"Log data locally for later sync",
"Alert maintenance teams"
],
"recovery_check_interval": 300 # seconds
}
# Example usage
if __name__ == "__main__":
# Monitor wind farm
turbine_list = [f"WT-{i:03d}" for i in range(1, 11)] # 10 turbines
weather = {"wind_speed": 14.0, "direction": "NW", "temperature": 15.0}
result = energy_monitoring_workflow(turbine_list, weather)
print("Energy monitoring result:", result)
# Demonstrate cross-industry adaptation
solar_monitor = EnergyPatternAdapter.adapt_for_solar_farm()
grid_balancer = EnergyPatternAdapter.adapt_for_power_grid()
battery_optimizer = EnergyPatternAdapter.adapt_for_battery_storage()
# Fallback examples
turbine_fallback = EnergyFallbackStrategies.turbine_failure_fallback(
"WT-003", ["WT-001", "WT-002", "WT-004", "WT-005"]
)
print("Turbine failure fallback:", turbine_fallback)
@@ -1,478 +0,0 @@
"""
Healthcare Industry Template
============================
Emergency triage and patient care coordination workflow
Based on SRAO Framework with HIPAA-compliant patterns
Key agents:
- VitalSignsCapture: Processes patient vital signs
- EMRRetrieval: Retrieves electronic medical records
- TriageRecommendation: Provides triage recommendations
- ResourceAllocator: Manages hospital resource allocation
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Optional
from pydantic import BaseModel
from datetime import datetime
from enum import Enum
# Triage levels following Emergency Severity Index (ESI)
class TriageLevel(str, Enum):
RESUSCITATION = "1_resuscitation" # Immediate life-saving intervention
EMERGENT = "2_emergent" # High risk, severe pain/distress
URGENT = "3_urgent" # Stable but needs multiple resources
LESS_URGENT = "4_less_urgent" # Stable, one resource needed
NON_URGENT = "5_non_urgent" # Stable, no resources needed
# I/O Schemas
class VitalSigns(BaseModel):
"""Patient vital signs data"""
patient_id: str
timestamp: str
heart_rate: int # bpm
blood_pressure_systolic: int # mmHg
blood_pressure_diastolic: int # mmHg
respiratory_rate: int # breaths/min
temperature: float # Celsius
oxygen_saturation: int # percentage
pain_scale: int # 0-10
consciousness_level: str # alert, verbal, pain, unresponsive (AVPU)
class MedicalHistory(BaseModel):
"""Patient medical history from EMR"""
patient_id: str
allergies: List[str]
chronic_conditions: List[str]
current_medications: List[Dict[str, str]]
recent_visits: List[Dict[str, Any]]
emergency_contacts: List[Dict[str, str]]
insurance_status: str
class TriageAssessment(BaseModel):
"""Triage assessment and recommendations"""
assessment_id: str
patient_id: str
triage_level: TriageLevel
chief_complaint: str
recommended_department: str
estimated_wait_time: int # minutes
required_resources: List[str]
clinical_notes: str
red_flags: List[str]
class ResourceAllocation(BaseModel):
"""Hospital resource allocation"""
allocation_id: str
patient_id: str
assigned_bed: Optional[str]
assigned_staff: List[str]
equipment_needed: List[str]
department: str
priority_score: float
estimated_treatment_time: int # minutes
# Healthcare-specific tools
@tool
def capture_vital_signs(patient_id: str) -> Dict:
"""Capture patient vital signs from monitoring devices"""
# Simulate vital signs capture
return {
"patient_id": patient_id,
"timestamp": datetime.now().isoformat(),
"heart_rate": 78,
"blood_pressure_systolic": 130,
"blood_pressure_diastolic": 85,
"respiratory_rate": 16,
"temperature": 37.2,
"oxygen_saturation": 98,
"pain_scale": 6,
"consciousness_level": "alert"
}
@tool
def retrieve_medical_history(patient_id: str, consent_verified: bool = True) -> Dict:
"""Retrieve patient medical history from EMR system"""
if not consent_verified:
return {"error": "consent_required", "message": "Patient consent required for EMR access"}
# Simulate EMR retrieval
return {
"patient_id": patient_id,
"allergies": ["penicillin", "latex"],
"chronic_conditions": ["hypertension", "diabetes_type2"],
"current_medications": [
{"name": "metformin", "dose": "500mg", "frequency": "twice daily"},
{"name": "lisinopril", "dose": "10mg", "frequency": "once daily"}
],
"recent_visits": [
{"date": "2024-02-15", "reason": "routine_checkup", "department": "primary_care"}
],
"emergency_contacts": [
{"name": "Jane Doe", "relationship": "spouse", "phone": "555-0123"}
],
"insurance_status": "active"
}
@tool
def calculate_triage_priority(vital_signs: Dict, medical_history: Dict, chief_complaint: str) -> Dict:
"""Calculate triage priority based on clinical indicators"""
# Simulate triage algorithm
priority_score = 0
red_flags = []
# Check vital signs
if vital_signs["heart_rate"] > 120 or vital_signs["heart_rate"] < 50:
priority_score += 3
red_flags.append("abnormal_heart_rate")
if vital_signs["oxygen_saturation"] < 92:
priority_score += 4
red_flags.append("low_oxygen")
if vital_signs["consciousness_level"] != "alert":
priority_score += 5
red_flags.append("altered_consciousness")
# Determine triage level
if priority_score >= 8:
triage_level = TriageLevel.RESUSCITATION
department = "emergency"
wait_time = 0
elif priority_score >= 5:
triage_level = TriageLevel.EMERGENT
department = "emergency"
wait_time = 10
elif priority_score >= 3:
triage_level = TriageLevel.URGENT
department = "urgent_care"
wait_time = 30
else:
triage_level = TriageLevel.LESS_URGENT
department = "primary_care"
wait_time = 60
return {
"assessment_id": f"TRG-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"patient_id": vital_signs["patient_id"],
"triage_level": triage_level.value,
"chief_complaint": chief_complaint,
"recommended_department": department,
"estimated_wait_time": wait_time,
"required_resources": ["physician", "nurse", "monitoring"],
"clinical_notes": f"Priority score: {priority_score}",
"red_flags": red_flags
}
@tool
def allocate_resources(patient_id: str, triage_level: str, department: str) -> Dict:
"""Allocate hospital resources based on triage assessment"""
# Simulate resource allocation
if "resuscitation" in triage_level:
bed = "ER-01"
staff = ["Dr. Smith", "Nurse Johnson", "Nurse Williams"]
equipment = ["cardiac_monitor", "defibrillator", "crash_cart"]
priority = 10.0
elif "emergent" in triage_level:
bed = "ER-05"
staff = ["Dr. Brown", "Nurse Davis"]
equipment = ["vital_signs_monitor", "IV_pump"]
priority = 8.0
else:
bed = "UC-12"
staff = ["PA Miller", "Nurse Garcia"]
equipment = ["basic_monitor"]
priority = 5.0
return {
"allocation_id": f"RES-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"patient_id": patient_id,
"assigned_bed": bed,
"assigned_staff": staff,
"equipment_needed": equipment,
"department": department,
"priority_score": priority,
"estimated_treatment_time": 45
}
# Healthcare agent definitions with SLA requirements
vital_signs_agent = Agent(
name="VitalSignsCapture",
instructions="""You are a clinical vital signs specialist.
Capture and interpret patient vital signs from various monitoring devices.
Identify abnormal values and trends that require immediate attention.
Follow HIPAA guidelines for patient data handling.
SLA: Capture and process within 30 seconds.""",
tools=[capture_vital_signs]
)
emr_agent = Agent(
name="EMRRetrieval",
instructions="""You are an EMR system specialist with HIPAA compliance expertise.
Retrieve relevant patient medical history while ensuring data privacy.
Verify patient consent and maintain audit trails.
Highlight relevant allergies and contraindications.
SLA: Retrieve records within 5 seconds.""",
tools=[retrieve_medical_history]
)
triage_agent = Agent(
name="TriageRecommendation",
instructions="""You are an emergency triage specialist following ESI guidelines.
Assess patient severity and provide triage recommendations.
Consider vital signs, medical history, and presenting symptoms.
Identify red flags that require immediate intervention.
SLA: Complete assessment within 1 minute.""",
tools=[calculate_triage_priority]
)
resource_agent = Agent(
name="ResourceAllocator",
instructions="""You are a hospital resource management specialist.
Allocate beds, staff, and equipment based on patient needs and priority.
Optimize resource utilization while maintaining quality of care.
Coordinate between departments for efficient patient flow.
SLA: Allocate resources within 30 seconds.""",
tools=[allocate_resources]
)
# Healthcare workflow with safety protocols
def emergency_triage_workflow(patient_id: str, chief_complaint: str):
"""
Complete emergency triage workflow from arrival to resource allocation
Includes safety checks and fallback protocols
"""
workflow_result = {
"patient_id": patient_id,
"timestamp": datetime.now().isoformat(),
"status": "in_progress",
"safety_checks": [],
"clinical_pathway": []
}
# Step 1: Vital signs capture with validation
try:
vital_signs = vital_signs_agent.start(
f"Capture vital signs for patient {patient_id}"
)
workflow_result["clinical_pathway"].append({
"step": "vital_signs",
"status": "completed",
"data": vital_signs
})
# Safety check: Validate vital signs are within possible ranges
if vital_signs.get("heart_rate", 0) < 20 or vital_signs.get("heart_rate", 0) > 300:
workflow_result["safety_checks"].append({
"type": "vital_signs_validation",
"issue": "impossible_heart_rate",
"action": "manual_verification_required"
})
except Exception as e:
# Fallback: Manual vital signs entry
workflow_result["clinical_pathway"].append({
"step": "vital_signs",
"status": "manual_entry_required",
"error": str(e)
})
vital_signs = {"manual_entry": True}
# Step 2: EMR retrieval with consent check
try:
medical_history = emr_agent.start(
f"Retrieve medical history for patient {patient_id} with verified consent"
)
workflow_result["clinical_pathway"].append({
"step": "emr_retrieval",
"status": "completed",
"records_retrieved": True
})
# Safety check: Allergy alerts
if medical_history.get("allergies"):
workflow_result["safety_checks"].append({
"type": "allergy_alert",
"allergies": medical_history["allergies"],
"action": "notify_all_providers"
})
except Exception as e:
# Fallback: Proceed with limited information
medical_history = {
"patient_id": patient_id,
"allergies": [],
"note": "EMR_unavailable_proceed_with_caution"
}
workflow_result["safety_checks"].append({
"type": "emr_unavailable",
"action": "collect_history_manually"
})
# Step 3: Triage assessment
try:
triage_assessment = triage_agent.start(
f"Assess triage for patient with vitals {vital_signs}, "
f"history {medical_history}, complaint: {chief_complaint}"
)
workflow_result["clinical_pathway"].append({
"step": "triage_assessment",
"status": "completed",
"triage_level": triage_assessment.get("triage_level")
})
# Safety check: Red flags requiring immediate action
if triage_assessment.get("red_flags"):
workflow_result["safety_checks"].append({
"type": "clinical_red_flags",
"flags": triage_assessment["red_flags"],
"action": "immediate_physician_notification"
})
except Exception as e:
# Fallback: Conservative triage (assume urgent)
triage_assessment = {
"triage_level": TriageLevel.URGENT.value,
"recommended_department": "emergency",
"fallback": True
}
# Step 4: Resource allocation
try:
resource_allocation = resource_agent.start(
f"Allocate resources for patient {patient_id} "
f"with triage {triage_assessment.get('triage_level')} "
f"to {triage_assessment.get('recommended_department')}"
)
workflow_result["clinical_pathway"].append({
"step": "resource_allocation",
"status": "completed",
"bed_assigned": resource_allocation.get("assigned_bed"),
"staff_assigned": resource_allocation.get("assigned_staff")
})
except Exception as e:
# Fallback: Queue for next available
workflow_result["clinical_pathway"].append({
"step": "resource_allocation",
"status": "queued",
"queue_position": "next_available",
"error": str(e)
})
workflow_result["status"] = "completed"
return workflow_result
# HIPAA-compliant data handling patterns
class HIPAACompliancePatterns:
"""
Reusable patterns for HIPAA-compliant healthcare workflows
"""
@staticmethod
def anonymize_patient_data(data: Dict) -> Dict:
"""Remove PHI for analytics and reporting"""
safe_fields = ["triage_level", "department", "wait_time", "resource_type"]
return {k: v for k, v in data.items() if k in safe_fields}
@staticmethod
def audit_log_access(user_id: str, patient_id: str, action: str, reason: str):
"""Create audit trail for EMR access"""
return {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"patient_id": patient_id,
"action": action,
"reason": reason,
"ip_address": "10.0.0.1", # Would be actual IP
"session_id": "SEC-" + datetime.now().strftime('%Y%m%d%H%M%S')
}
@staticmethod
def encrypt_sensitive_data(data: Dict) -> Dict:
"""Encrypt sensitive fields (placeholder for real encryption)"""
sensitive_fields = ["ssn", "dob", "address", "phone"]
encrypted = data.copy()
for field in sensitive_fields:
if field in encrypted:
encrypted[field] = f"ENCRYPTED_{field.upper()}"
return encrypted
# Cross-department coordination patterns
class HealthcareCoordinationPatterns:
"""
Patterns for coordinating across healthcare departments
"""
@staticmethod
def coordinate_lab_orders(patient_id: str, triage_level: str) -> Agent:
"""Create agent for lab order coordination"""
return Agent(
name="LabCoordinator",
instructions=f"""Coordinate laboratory orders for patient {patient_id}.
Priority level: {triage_level}.
Ensure STAT orders for critical patients.
Track specimen collection and result delivery.
SLA: Order placement within 2 minutes."""
)
@staticmethod
def coordinate_radiology(patient_id: str, imaging_type: str) -> Agent:
"""Create agent for radiology coordination"""
return Agent(
name="RadiologyCoordinator",
instructions=f"""Schedule {imaging_type} imaging for patient {patient_id}.
Check for contrast allergies and pregnancy status.
Coordinate with transport for patient movement.
SLA: Schedule within 5 minutes for urgent cases."""
)
@staticmethod
def coordinate_pharmacy(patient_id: str, medications: List[str]) -> Agent:
"""Create agent for pharmacy coordination"""
return Agent(
name="PharmacyCoordinator",
instructions=f"""Verify and dispense medications for patient {patient_id}.
Check for drug interactions and allergies.
Ensure proper dosing based on patient parameters.
SLA: Medication verification within 3 minutes."""
)
# Example usage
if __name__ == "__main__":
# Process emergency patient
patient_id = "PT-2024-00123"
chief_complaint = "severe chest pain radiating to left arm"
result = emergency_triage_workflow(patient_id, chief_complaint)
print("Triage workflow result:", result)
# Create specialized coordinators
lab_coordinator = HealthcareCoordinationPatterns.coordinate_lab_orders(
patient_id, TriageLevel.EMERGENT.value
)
radiology_coordinator = HealthcareCoordinationPatterns.coordinate_radiology(
patient_id, "chest_xray"
)
pharmacy_coordinator = HealthcareCoordinationPatterns.coordinate_pharmacy(
patient_id, ["aspirin", "nitroglycerin"]
)
# HIPAA compliance example
audit_log = HIPAACompliancePatterns.audit_log_access(
"DR-001", patient_id, "EMR_ACCESS", "emergency_triage"
)
print("Audit log created:", audit_log)
@@ -1,298 +0,0 @@
"""
Manufacturing Industry Template
================================
Order scheduling, inventory management, and quality inspection workflow
Based on SRAO Framework patterns with 70% cross-industry reuse
Key agents:
- ParseOrder: Extracts order details from various formats
- CheckInventory: Validates material availability
- OptimizeSchedule: Optimizes production scheduling
- DefectDetect: Quality control and defect detection
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any
from pydantic import BaseModel
# I/O Schemas
class OrderDetails(BaseModel):
"""Structured order information"""
order_id: str
customer_id: str
product_id: str
quantity: int
priority: str = "normal" # normal, high, urgent
due_date: str
requirements: Dict[str, Any] = {}
class InventoryStatus(BaseModel):
"""Material availability status"""
material_id: str
available_quantity: int
reorder_level: int
supplier_lead_time: int
location: str
class ProductionSchedule(BaseModel):
"""Optimized production schedule"""
schedule_id: str
production_line: str
start_time: str
end_time: str
assigned_workers: List[str]
estimated_completion: str
bottleneck_analysis: Dict[str, Any] = {}
class QualityReport(BaseModel):
"""Quality inspection results"""
batch_id: str
inspection_time: str
defect_rate: float
defect_types: List[str]
passed: bool
recommendations: List[str]
# Tools for agents
@tool
def extract_order_details(text: str) -> Dict:
"""Extract structured order information from unstructured text"""
# Simulate order parsing logic
return {
"order_id": "ORD-2024-001",
"customer_id": "CUST-123",
"product_id": "PROD-456",
"quantity": 100,
"priority": "high",
"due_date": "2024-03-15",
"requirements": {"material": "steel", "finish": "polished"}
}
@tool
def check_material_availability(product_id: str, quantity: int) -> Dict:
"""Check if materials are available for production"""
# Simulate inventory check
return {
"material_id": "MAT-789",
"available_quantity": 500,
"reorder_level": 100,
"supplier_lead_time": 7,
"location": "Warehouse-A"
}
@tool
def calculate_optimal_schedule(order: Dict, inventory: Dict) -> Dict:
"""Calculate optimal production schedule based on constraints"""
# Simulate scheduling optimization
return {
"schedule_id": "SCH-2024-001",
"production_line": "Line-3",
"start_time": "2024-03-10T08:00:00",
"end_time": "2024-03-14T17:00:00",
"assigned_workers": ["Worker-001", "Worker-002"],
"estimated_completion": "2024-03-14T16:30:00",
"bottleneck_analysis": {
"critical_path": "Assembly",
"buffer_time": 2.5
}
}
@tool
def perform_quality_inspection(batch_id: str) -> Dict:
"""Perform automated quality inspection on production batch"""
# Simulate quality control
return {
"batch_id": batch_id,
"inspection_time": "2024-03-14T17:00:00",
"defect_rate": 0.02,
"defect_types": ["surface_scratch", "dimension_variance"],
"passed": True,
"recommendations": [
"Adjust machine calibration on Line-3",
"Increase inspection frequency for next batch"
]
}
# Agent Definitions with SLA requirements
order_parser = Agent(
name="ParseOrder",
instructions="""You are an order parsing specialist. Extract and structure order information
from various formats (email, PDF, API). Ensure all required fields are captured and validated.
SLA: Process within 30 seconds per order.""",
tools=[extract_order_details]
)
inventory_checker = Agent(
name="CheckInventory",
instructions="""You are an inventory management specialist. Verify material availability,
check reorder points, and identify potential supply chain issues.
SLA: Real-time response within 5 seconds.""",
tools=[check_material_availability]
)
schedule_optimizer = Agent(
name="OptimizeSchedule",
instructions="""You are a production scheduling expert. Optimize production schedules
based on order priority, resource availability, and constraints. Minimize idle time
and maximize throughput.
SLA: Generate schedule within 2 minutes for complex orders.""",
tools=[calculate_optimal_schedule]
)
quality_inspector = Agent(
name="DefectDetect",
instructions="""You are a quality control specialist. Perform automated inspections,
detect defects using vision analysis, and provide improvement recommendations.
SLA: Complete inspection within 1 minute per batch.""",
tools=[perform_quality_inspection]
)
# Workflow with fallback strategies
def manufacturing_workflow(order_text: str):
"""
Complete manufacturing workflow from order to quality control
Includes fallback strategies for each critical step
"""
# Step 1: Parse order with fallback
try:
order_details = order_parser.start(f"Parse this order: {order_text}")
if not order_details:
# Fallback: Manual review queue
return {"status": "manual_review_required", "reason": "order_parsing_failed"}
except Exception as e:
return {"status": "error", "stage": "order_parsing", "error": str(e)}
# Step 2: Check inventory with fallback
try:
inventory_status = inventory_checker.start(
f"Check materials for order: {order_details}"
)
if not inventory_status:
# Fallback: Alternative supplier check
return {"status": "alternative_supplier_needed", "order": order_details}
except Exception as e:
# Fallback: Conservative estimate with safety margin
inventory_status = {"estimated": True, "safety_margin": 1.5}
# Step 3: Optimize schedule with fallback
try:
schedule = schedule_optimizer.start(
f"Create schedule for order {order_details} with inventory {inventory_status}"
)
if not schedule:
# Fallback: Standard scheduling template
schedule = {"template": "standard", "priority": "normal"}
except Exception as e:
# Fallback: Queue for next available slot
schedule = {"status": "queued", "estimated_start": "next_available"}
# Step 4: Quality inspection with fallback
try:
quality_report = quality_inspector.start(
f"Inspect batch for schedule {schedule}"
)
if not quality_report:
# Fallback: Manual inspection required
quality_report = {"requires_manual": True, "inspector_assigned": "QC-Team-1"}
except Exception as e:
# Fallback: Quarantine for review
quality_report = {"status": "quarantined", "review_required": True}
return {
"status": "completed",
"order": order_details,
"inventory": inventory_status,
"schedule": schedule,
"quality": quality_report
}
# Cross-industry reusable patterns (70% reuse)
class IndustryAgentPattern:
"""
Base pattern reusable across industries
Provides common agent capabilities that can be specialized
"""
@staticmethod
def create_data_parser(name: str, domain: str, sla_seconds: int = 30):
"""Create a data parsing agent for any domain"""
return Agent(
name=name,
instructions=f"""You are a {domain} data parsing specialist.
Extract and structure information from various formats.
Validate all required fields according to {domain} standards.
SLA: Process within {sla_seconds} seconds."""
)
@staticmethod
def create_resource_checker(name: str, resource_type: str, sla_seconds: int = 5):
"""Create a resource availability checker for any resource type"""
return Agent(
name=name,
instructions=f"""You are a {resource_type} availability specialist.
Verify {resource_type} availability and identify constraints.
Provide real-time status and alternative options if needed.
SLA: Response within {sla_seconds} seconds."""
)
@staticmethod
def create_optimizer(name: str, optimization_target: str, sla_minutes: int = 2):
"""Create an optimization agent for any target metric"""
return Agent(
name=name,
instructions=f"""You are an optimization expert for {optimization_target}.
Find optimal solutions considering all constraints and priorities.
Minimize waste and maximize efficiency.
SLA: Generate solution within {sla_minutes} minutes."""
)
@staticmethod
def create_inspector(name: str, inspection_type: str, sla_minutes: int = 1):
"""Create an inspection agent for any quality metric"""
return Agent(
name=name,
instructions=f"""You are a {inspection_type} inspection specialist.
Perform automated analysis and detect anomalies.
Provide actionable recommendations for improvement.
SLA: Complete inspection within {sla_minutes} minute(s)."""
)
# Example usage
if __name__ == "__main__":
# Create a manufacturing workflow instance
order = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
# Option 1: Use specialized agents
result = manufacturing_workflow(order)
print("Manufacturing workflow result:", result)
# Option 2: Use reusable patterns for other industries
# These patterns can be adapted for Energy, Healthcare, Agriculture, Transportation
energy_parser = IndustryAgentPattern.create_data_parser(
"EnergyDataParser", "wind farm telemetry", sla_seconds=10
)
healthcare_checker = IndustryAgentPattern.create_resource_checker(
"BedAvailability", "hospital bed", sla_seconds=3
)
agriculture_optimizer = IndustryAgentPattern.create_optimizer(
"IrrigationOptimizer", "water usage", sla_minutes=5
)
transport_inspector = IndustryAgentPattern.create_inspector(
"TunnelSafety", "structural integrity", sla_minutes=2
)
@@ -1,599 +0,0 @@
"""
Transportation Industry Template
================================
Tunnel safety monitoring and infrastructure management workflow
Based on SRAO Framework with LiDAR and sensor fusion
Key agents:
- LiDARFusion: Processes LiDAR point cloud data
- DisplacementCalculator: Calculates structural displacement
- HeatmapGenerator: Generates safety heatmaps
- MaintenancePlanner: Plans infrastructure maintenance
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Tuple, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
from enum import Enum
import math
# Safety levels for infrastructure
class SafetyLevel(str, Enum):
SAFE = "safe"
CAUTION = "caution"
WARNING = "warning"
DANGER = "danger"
CRITICAL = "critical"
# Infrastructure types
class InfrastructureType(str, Enum):
TUNNEL = "tunnel"
BRIDGE = "bridge"
HIGHWAY = "highway"
RAILWAY = "railway"
AIRPORT = "airport"
# I/O Schemas
class LiDARScan(BaseModel):
"""LiDAR scan data for infrastructure"""
scan_id: str
infrastructure_id: str
scan_time: str
point_cloud_size: int # Number of points
scan_resolution: float # Points per square meter
coverage_area: float # Square meters
anomaly_points: List[Tuple[float, float, float]] # XYZ coordinates
reference_baseline: Optional[Dict[str, Any]]
class DisplacementAnalysis(BaseModel):
"""Structural displacement analysis"""
analysis_id: str
infrastructure_id: str
analysis_time: str
max_displacement: float # millimeters
displacement_vector: Tuple[float, float, float] # XYZ displacement
critical_zones: List[Dict[str, Any]]
settlement_rate: float # mm/month
tilt_angle: float # degrees
safety_factor: float
class SafetyHeatmap(BaseModel):
"""Infrastructure safety heatmap"""
heatmap_id: str
infrastructure_id: str
generation_time: str
grid_resolution: float # meters
safety_scores: List[List[float]] # 2D grid of safety scores
critical_areas: List[Dict[str, Any]]
overall_safety: SafetyLevel
risk_trends: Dict[str, Any]
class MaintenanceSchedule(BaseModel):
"""Maintenance planning schedule"""
schedule_id: str
infrastructure_id: str
maintenance_type: str # preventive, corrective, emergency
priority_level: str # low, medium, high, critical
scheduled_date: str
estimated_duration: int # hours
required_resources: List[str]
traffic_impact: str
cost_estimate: float
# Transportation-specific tools
@tool
def process_lidar_scan(infrastructure_id: str, scan_type: str = "full") -> Dict:
"""Process LiDAR point cloud data for structural analysis"""
# Simulate LiDAR processing
return {
"scan_id": f"LIDAR-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": infrastructure_id,
"scan_time": datetime.now().isoformat(),
"point_cloud_size": 5000000, # 5 million points
"scan_resolution": 100.0, # points/m²
"coverage_area": 50000.0, # m²
"anomaly_points": [
(10.5, 20.3, 5.1),
(15.2, 25.8, 5.3),
(20.1, 30.5, 5.5)
],
"reference_baseline": {
"date": "2024-01-01",
"baseline_id": "BSL-001"
}
}
@tool
def calculate_displacement(current_scan: Dict, baseline_scan: Dict) -> Dict:
"""Calculate structural displacement from LiDAR scans"""
# Simulate displacement calculation
anomalies = current_scan.get("anomaly_points", [])
if len(anomalies) > 5:
max_displacement = 15.0
safety_factor = 0.6
safety_level = SafetyLevel.WARNING
elif len(anomalies) > 2:
max_displacement = 8.0
safety_factor = 0.8
safety_level = SafetyLevel.CAUTION
else:
max_displacement = 3.0
safety_factor = 0.95
safety_level = SafetyLevel.SAFE
return {
"analysis_id": f"DISP-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": current_scan["infrastructure_id"],
"analysis_time": datetime.now().isoformat(),
"max_displacement": max_displacement,
"displacement_vector": (2.5, 1.8, max_displacement),
"critical_zones": [
{
"zone_id": "CZ-001",
"location": "tunnel_crown",
"displacement": max_displacement * 0.8,
"risk": "high" if max_displacement > 10 else "medium"
}
],
"settlement_rate": max_displacement / 30, # mm/month
"tilt_angle": math.degrees(math.atan(max_displacement / 1000)),
"safety_factor": safety_factor
}
@tool
def generate_safety_heatmap(displacement_data: Dict, sensor_data: List[Dict]) -> Dict:
"""Generate safety heatmap visualization for infrastructure"""
# Simulate heatmap generation
safety_factor = displacement_data.get("safety_factor", 1.0)
# Create grid (simplified 10x10)
grid_size = 10
safety_grid = []
critical_areas = []
for i in range(grid_size):
row = []
for j in range(grid_size):
# Generate safety score based on position and displacement
base_score = safety_factor
distance_factor = math.sqrt((i-5)**2 + (j-5)**2) / 10
safety_score = min(1.0, base_score + (0.2 * distance_factor))
row.append(safety_score)
if safety_score < 0.7:
critical_areas.append({
"grid_position": (i, j),
"safety_score": safety_score,
"recommended_action": "immediate_inspection"
})
safety_grid.append(row)
# Determine overall safety
avg_safety = sum(sum(row) for row in safety_grid) / (grid_size * grid_size)
if avg_safety > 0.9:
overall = SafetyLevel.SAFE
elif avg_safety > 0.8:
overall = SafetyLevel.CAUTION
elif avg_safety > 0.7:
overall = SafetyLevel.WARNING
else:
overall = SafetyLevel.DANGER
return {
"heatmap_id": f"HEAT-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": displacement_data["infrastructure_id"],
"generation_time": datetime.now().isoformat(),
"grid_resolution": 5.0, # meters
"safety_scores": safety_grid,
"critical_areas": critical_areas,
"overall_safety": overall.value,
"risk_trends": {
"trend": "deteriorating" if avg_safety < 0.8 else "stable",
"rate": -0.02 if avg_safety < 0.8 else 0.0
}
}
@tool
def plan_maintenance(infrastructure_id: str, safety_analysis: Dict, traffic_data: Dict) -> Dict:
"""Plan maintenance based on safety analysis and traffic patterns"""
safety_level = safety_analysis.get("overall_safety", "safe")
# Determine maintenance urgency
if safety_level in ["danger", "critical"]:
maintenance_type = "emergency"
priority = "critical"
days_until = 1
duration = 48
elif safety_level == "warning":
maintenance_type = "corrective"
priority = "high"
days_until = 7
duration = 24
elif safety_level == "caution":
maintenance_type = "preventive"
priority = "medium"
days_until = 30
duration = 12
else:
maintenance_type = "preventive"
priority = "low"
days_until = 90
duration = 8
# Traffic impact assessment
peak_traffic = traffic_data.get("peak_volume", 1000)
if peak_traffic > 5000:
traffic_impact = "severe"
cost_multiplier = 2.0
elif peak_traffic > 2000:
traffic_impact = "moderate"
cost_multiplier = 1.5
else:
traffic_impact = "minimal"
cost_multiplier = 1.0
return {
"schedule_id": f"MAINT-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": infrastructure_id,
"maintenance_type": maintenance_type,
"priority_level": priority,
"scheduled_date": (datetime.now() + timedelta(days=days_until)).isoformat(),
"estimated_duration": duration,
"required_resources": [
"inspection_team",
"repair_crew",
"safety_equipment",
"traffic_control"
],
"traffic_impact": traffic_impact,
"cost_estimate": 50000 * cost_multiplier * (duration / 24)
}
# Transportation agent definitions
lidar_agent = Agent(
name="LiDARFusion",
instructions="""You are a LiDAR data processing specialist for infrastructure.
Process point cloud data from mobile and terrestrial LiDAR scanners.
Perform data fusion from multiple sensor sources.
Identify structural anomalies and deformations.
SLA: Process scan data within 5 minutes.""",
tools=[process_lidar_scan]
)
displacement_agent = Agent(
name="DisplacementCalculator",
instructions="""You are a structural displacement analysis expert.
Calculate precise displacement measurements from temporal scans.
Analyze settlement patterns and structural movements.
Predict future displacement trends using ML models.
SLA: Complete analysis within 2 minutes.""",
tools=[calculate_displacement]
)
heatmap_agent = Agent(
name="HeatmapGenerator",
instructions="""You are a safety visualization specialist.
Generate intuitive safety heatmaps for infrastructure monitoring.
Integrate multiple data sources for comprehensive risk assessment.
Highlight critical areas requiring immediate attention.
SLA: Generate heatmap within 1 minute.""",
tools=[generate_safety_heatmap]
)
maintenance_agent = Agent(
name="MaintenancePlanner",
instructions="""You are an infrastructure maintenance planning expert.
Schedule maintenance based on structural health and traffic patterns.
Optimize maintenance windows to minimize disruption.
Coordinate resources and estimate costs.
SLA: Create maintenance plan within 3 minutes.""",
tools=[plan_maintenance]
)
# Transportation infrastructure monitoring workflow
def infrastructure_monitoring_workflow(
infrastructure_ids: List[str],
infrastructure_type: InfrastructureType,
traffic_data: Dict
):
"""
Complete infrastructure monitoring workflow from scanning to maintenance
Includes safety assessment and predictive maintenance
"""
monitoring_results = {
"timestamp": datetime.now().isoformat(),
"infrastructure_type": infrastructure_type.value,
"structures_monitored": len(infrastructure_ids),
"safety_assessments": [],
"maintenance_required": [],
"emergency_alerts": [],
"network_health_score": 0.0
}
total_safety_score = 0.0
for infra_id in infrastructure_ids:
try:
# Step 1: LiDAR scanning
lidar_scan = lidar_agent.start(
f"Process LiDAR scan for {infrastructure_type.value} {infra_id}"
)
# Step 2: Displacement analysis
# Get baseline (would be from database in real scenario)
baseline = {"scan_id": "BSL-001", "point_cloud": []}
displacement_analysis = displacement_agent.start(
f"Calculate displacement for scan: {lidar_scan} against baseline: {baseline}"
)
# Step 3: Generate safety heatmap
sensor_data = [] # Would include IoT sensor readings
safety_heatmap = heatmap_agent.start(
f"Generate heatmap for displacement: {displacement_analysis} "
f"with sensors: {sensor_data}"
)
monitoring_results["safety_assessments"].append({
"infrastructure_id": infra_id,
"safety_level": safety_heatmap.get("overall_safety"),
"max_displacement": displacement_analysis.get("max_displacement"),
"critical_zones": len(safety_heatmap.get("critical_areas", []))
})
# Check for emergency conditions
if safety_heatmap.get("overall_safety") in ["danger", "critical"]:
monitoring_results["emergency_alerts"].append({
"infrastructure_id": infra_id,
"alert_level": "emergency",
"safety_status": safety_heatmap["overall_safety"],
"immediate_action": "close_to_traffic",
"notification_sent": True
})
# Step 4: Emergency maintenance planning
maintenance_plan = maintenance_agent.start(
f"Plan emergency maintenance for {infra_id} "
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
)
monitoring_results["maintenance_required"].append(maintenance_plan)
elif safety_heatmap.get("overall_safety") in ["warning", "caution"]:
# Regular maintenance planning
maintenance_plan = maintenance_agent.start(
f"Plan maintenance for {infra_id} "
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
)
monitoring_results["maintenance_required"].append(maintenance_plan)
# Calculate safety score contribution
safety_mapping = {
"safe": 1.0, "caution": 0.8, "warning": 0.6,
"danger": 0.3, "critical": 0.1
}
total_safety_score += safety_mapping.get(
safety_heatmap.get("overall_safety", "safe"), 0.5
)
except Exception as e:
# Fallback: Manual inspection
monitoring_results["emergency_alerts"].append({
"infrastructure_id": infra_id,
"alert_level": "system_failure",
"error": str(e),
"action": "dispatch_manual_inspection_team"
})
# Calculate network health score
if infrastructure_ids:
monitoring_results["network_health_score"] = (
total_safety_score / len(infrastructure_ids) * 100
)
return monitoring_results
# Multi-modal transportation patterns
class MultiModalTransportPatterns:
"""
Patterns for different transportation infrastructure types
Demonstrates cross-industry reuse for transportation sector
"""
@staticmethod
def adapt_for_bridge_monitoring() -> Agent:
"""Adapt tunnel monitoring for bridge structures"""
return Agent(
name="BridgeMonitor",
instructions="""Monitor bridge structural health using sensors.
Track deck deflection, cable tension, and vibration patterns.
Detect fatigue cracks and corrosion.
Consider wind and seismic loads.
SLA: Real-time monitoring with 10-second updates."""
)
@staticmethod
def adapt_for_railway_tracks() -> Agent:
"""Adapt for railway track monitoring"""
return Agent(
name="RailwayMonitor",
instructions="""Monitor railway track geometry and condition.
Detect rail defects, gauge variations, and ballast degradation.
Analyze wheel-rail interaction forces.
Predict track maintenance needs.
SLA: Process track inspection data within 3 minutes per km."""
)
@staticmethod
def adapt_for_airport_runways() -> Agent:
"""Adapt for airport runway monitoring"""
return Agent(
name="RunwayMonitor",
instructions="""Monitor airport runway surface conditions.
Detect FOD (Foreign Object Debris) and surface irregularities.
Assess pavement condition and friction levels.
Monitor drainage and lighting systems.
SLA: Complete runway scan within 10 minutes."""
)
# Intelligent traffic management integration
class TrafficManagementPatterns:
"""
Patterns for integrating with intelligent traffic systems
"""
@staticmethod
def optimize_traffic_flow(maintenance_window: Dict, traffic_patterns: Dict) -> Dict:
"""Optimize traffic flow during maintenance"""
peak_hours = traffic_patterns.get("peak_hours", [7, 9, 17, 19])
maintenance_start = maintenance_window.get("start_time", "02:00")
# Calculate optimal diversion routes
return {
"strategy": "dynamic_routing",
"diversion_routes": [
{"route_id": "ALT-1", "capacity": 0.6, "added_time": 15},
{"route_id": "ALT-2", "capacity": 0.3, "added_time": 20}
],
"lane_management": {
"reversible_lanes": True,
"peak_direction": "inbound" if int(maintenance_start[:2]) < 12 else "outbound"
},
"public_transport": {
"increase_frequency": True,
"additional_services": 4
},
"estimated_delay": 10 # minutes
}
@staticmethod
def incident_response_plan(incident_type: str, location: Dict) -> Dict:
"""Generate incident response plan"""
response_times = {
"accident": 5,
"breakdown": 10,
"infrastructure_failure": 2,
"weather_hazard": 15
}
return {
"incident_id": f"INC-{datetime.now().strftime('%Y%m%d%H%M')}",
"response_time": response_times.get(incident_type, 10),
"resources_dispatched": [
r for r in [
"emergency_response_team",
"traffic_control_unit",
"maintenance_crew" if "infrastructure" in incident_type else None
] if r is not None
],
"traffic_management": {
"lane_closures": 2,
"speed_reduction": 30, # km/h
"message_signs": ["Incident ahead", "Reduce speed", "Merge left"]
},
"estimated_clearance": 45 # minutes
}
# Predictive maintenance patterns
class PredictiveMaintenancePatterns:
"""
ML-based predictive maintenance for transportation infrastructure
"""
@staticmethod
def predict_failure_probability(
displacement_trend: List[float],
environmental_factors: Dict
) -> float:
"""Predict probability of structural failure"""
# Simplified prediction model
trend_slope = (displacement_trend[-1] - displacement_trend[0]) / len(displacement_trend)
temperature_factor = abs(environmental_factors.get("temp_variation", 0)) / 50
moisture_factor = environmental_factors.get("humidity", 50) / 100
base_probability = 0.1
trend_contribution = max(0, trend_slope * 10)
env_contribution = (temperature_factor + moisture_factor) * 0.2
return min(1.0, base_probability + trend_contribution + env_contribution)
@staticmethod
def optimize_maintenance_schedule(
infrastructure_conditions: List[Dict],
budget_constraint: float
) -> List[Dict]:
"""Optimize maintenance schedule across infrastructure network"""
# Sort by urgency (safety_factor * cost_efficiency)
prioritized = sorted(
infrastructure_conditions,
key=lambda x: (1 - x.get("safety_factor", 1)) * (1 / x.get("cost", 1)),
reverse=True
)
scheduled = []
total_cost = 0
for infra in prioritized:
if total_cost + infra.get("cost", 0) <= budget_constraint:
scheduled.append({
"infrastructure_id": infra["id"],
"scheduled_date": infra["recommended_date"],
"priority": "high" if infra["safety_factor"] < 0.7 else "medium"
})
total_cost += infra.get("cost", 0)
return scheduled
# Example usage
if __name__ == "__main__":
# Monitor tunnel network
tunnel_ids = [f"TUN-{i:03d}" for i in range(1, 6)]
traffic = {
"peak_volume": 3000,
"off_peak_volume": 800,
"peak_hours": [7, 9, 17, 19]
}
result = infrastructure_monitoring_workflow(
tunnel_ids,
InfrastructureType.TUNNEL,
traffic
)
print("Infrastructure monitoring result:", result)
# Multi-modal adaptation examples
bridge_monitor = MultiModalTransportPatterns.adapt_for_bridge_monitoring()
railway_monitor = MultiModalTransportPatterns.adapt_for_railway_tracks()
runway_monitor = MultiModalTransportPatterns.adapt_for_airport_runways()
# Traffic management example
maintenance = {"start_time": "22:00", "duration": 8}
traffic_plan = TrafficManagementPatterns.optimize_traffic_flow(maintenance, traffic)
print("Traffic optimization plan:", traffic_plan)
# Predictive maintenance example
displacement_history = [2.0, 2.3, 2.8, 3.5, 4.3] # mm over months
environment = {"temp_variation": 30, "humidity": 75}
failure_prob = PredictiveMaintenancePatterns.predict_failure_probability(
displacement_history, environment
)
print(f"Failure probability: {failure_prob:.2%}")
+12 -16
View File
@@ -6,18 +6,10 @@ It includes self-reflection for improving code quality and multiple specialized
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.tools import CodeInterpreter
def run_python_code(code: str) -> str:
"""Execute Python code safely and return stdout or errors."""
import io
import contextlib
try:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec(code, {"__builtins__": __builtins__})
return buf.getvalue() or "Executed successfully (no output)"
except Exception as e:
return f"Execution error: {e}"
# Initialize code execution tool
code_interpreter = CodeInterpreter()
# Custom code analysis tool
def analyze_code_complexity(code: str) -> str:
@@ -40,7 +32,9 @@ code_writer = Agent(
goal="Write clean, efficient, and well-documented Python code",
backstory="You are a senior Python developer with 10+ years of experience in writing production-quality code.",
instructions="Write Python code that follows PEP 8 standards, includes proper error handling, and has clear documentation.",
output="silent",
reflection=True,
)
code_reviewer = Agent(
@@ -58,7 +52,7 @@ code_executor = Agent(
goal="Safely execute and test Python code",
backstory="You are responsible for running code and reporting results or errors.",
instructions="Execute the provided code safely and report the output or any errors encountered.",
tools=[run_python_code]
tools=[code_interpreter]
)
# Example tasks
@@ -93,7 +87,7 @@ def demonstrate_code_agent():
workflow = AgentTeam(
agents=[code_writer, code_reviewer, code_executor],
tasks=[write_task, review_task, execute_task],
process="sequential", output="silent"
process="sequential", output="verbose"
)
# Run the workflow
@@ -112,8 +106,10 @@ general_code_agent = Agent(
3. Explain code concepts
4. Refactor code for better performance
5. Add tests and documentation""",
tools=[run_python_code, analyze_code_complexity],
output="silent",
tools=[code_interpreter, analyze_code_complexity],
reflection=True,
)
if __name__ == "__main__":
+16 -19
View File
@@ -41,25 +41,21 @@ def calculate_statistics(numbers: list) -> str:
"""Calculate basic statistics for a list of numbers."""
if not numbers:
return "No numbers provided"
try:
nums = [float(x) for x in numbers]
except (TypeError, ValueError):
return "Error: all values must be numbers"
n = len(nums)
mean = sum(nums) / n
sorted_nums = sorted(nums)
n = len(numbers)
mean = sum(numbers) / n
sorted_nums = sorted(numbers)
median = sorted_nums[n//2] if n % 2 else (sorted_nums[n//2-1] + sorted_nums[n//2]) / 2
variance = sum((x - mean)**2 for x in nums) / n
variance = sum((x - mean)**2 for x in numbers) / n
std_dev = math.sqrt(variance)
return f"""Statistics:
- Count: {n}
- Mean: {mean:.4f}
- Median: {median:.4f}
- Standard Deviation: {std_dev:.4f}
- Min: {min(nums)}
- Max: {max(nums)}"""
- Min: {min(numbers)}
- Max: {max(numbers)}"""
# Create specialized math agents
calculation_agent = Agent(
@@ -70,7 +66,8 @@ calculation_agent = Agent(
instructions="Perform the requested calculations step by step, showing your work clearly.",
tools=[basic_calculator, solve_quadratic, calculate_statistics],
reflection=True,
output="silent",
)
problem_solver_agent = Agent(
@@ -85,7 +82,8 @@ problem_solver_agent = Agent(
4. Show each step of the solution
5. Verify the answer makes sense""",
reflection=True,
output="silent",
)
math_tutor_agent = Agent(
@@ -93,8 +91,7 @@ math_tutor_agent = Agent(
role="Mathematics tutor and explainer",
goal="Explain mathematical concepts clearly and help users understand math",
backstory="You are a patient math tutor who can explain concepts at any level from elementary to advanced.",
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
output="silent",
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding."
)
# Example workflow for solving complex problems
@@ -129,8 +126,7 @@ def solve_math_problem_workflow():
workflow = AgentTeam(
agents=[problem_solver_agent, calculation_agent, math_tutor_agent],
tasks=[understand_task, solve_task, verify_task],
process="sequential",
output="silent",
process="sequential", output="verbose"
)
return workflow.start()
@@ -152,7 +148,8 @@ math_assistant = Agent(
Always show your work step by step.""",
tools=[basic_calculator, solve_quadratic, calculate_statistics],
reflection=True,
output="silent",
)
if __name__ == "__main__":
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""
Example: Bot In-Flight Run Control
Demonstrates how to use SessionRunControl to provide better UX for bots
during long-running agent operations. Shows busy feedback, pending message
handling, and /stop command support.
This example shows the "before" and "after" behavior described in issue #1914.
"""
import asyncio
import time
from typing import Optional
# Mock agent for demonstration
class MockAgent:
"""Simple mock agent that simulates long-running tasks."""
def __init__(self, name="assistant"):
self.name = name
self.chat_history = []
self._interrupt_controller = None
def chat(self, prompt: str) -> str:
"""Simulate a long-running chat response."""
print(f"[Agent] Starting to process: '{prompt[:50]}...'")
# Simulate long processing with interrupt checking
for i in range(30): # 3 second task (30 x 0.1s)
time.sleep(0.1)
# Check for interruption
if (self._interrupt_controller and
hasattr(self._interrupt_controller, 'is_set') and
self._interrupt_controller.is_set()):
reason = getattr(self._interrupt_controller, 'reason', 'unknown')
print(f"[Agent] ⚠️ Interrupted: {reason}")
raise InterruptedError(f"Task cancelled: {reason}")
response = f"Completed analysis of: {prompt}"
print(f"[Agent] ✅ Finished: {response}")
return response
async def demo_without_run_control():
"""Demonstrate the old behavior (silent blocking)."""
print("\n" + "="*60)
print("🔴 BEFORE: Without Run Control (Silent Blocking)")
print("="*60)
from praisonai.bots._session import BotSessionManager
# Standard session manager (no run control)
session_mgr = BotSessionManager()
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User sends long task:")
print(" User: 'research quantum computing trends'")
# Start first task (this will run for 3 seconds)
task1 = asyncio.create_task(
session_mgr.chat(agent, user_id, "research quantum computing trends")
)
# Simulate user sending follow-up messages during execution
await asyncio.sleep(0.5)
print("\n2. User sends follow-up (this will block silently):")
print(" User: 'actually focus on business impact'")
task2 = asyncio.create_task(
session_mgr.chat(agent, user_id, "actually focus on business impact")
)
await asyncio.sleep(0.5)
print("\n3. User tries to send /stop (this will also block):")
print(" User: '/stop'")
task3 = asyncio.create_task(
session_mgr.chat(agent, user_id, "/stop")
)
print("\n⏳ Waiting for all messages to complete...")
results = await asyncio.gather(task1, task2, task3)
print("\n📊 Results (all processed sequentially with no feedback):")
for i, result in enumerate(results, 1):
print(f" {i}. {result}")
print("\n❌ Problems with this approach:")
print(" - No immediate feedback on follow-up messages")
print(" - No way to cancel long-running tasks")
print(" - User doesn't know if bot is working or broken")
async def demo_with_run_control():
"""Demonstrate the new behavior with run control."""
print("\n" + "="*60)
print("🟢 AFTER: With Run Control (Better UX)")
print("="*60)
from praisonai.bots._run_control import SessionRunControl
from praisonai.bots._session import BotSessionManager
from praisonai.bots._commands import handle_stop_command
# Session manager with run control
run_control = SessionRunControl(
busy_mode="queue",
busy_ack_template="⏳ {action} — will process after current task finishes"
)
session_mgr = BotSessionManager(run_control=run_control)
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User sends long task:")
print(" User: 'research quantum computing trends'")
# Start first task
task1 = asyncio.create_task(
session_mgr.chat_with_run_control(agent, user_id, "research quantum computing trends")
)
# Immediate follow-up messages get acknowledgments
await asyncio.sleep(0.5)
print("\n2. User sends follow-up (gets immediate feedback):")
print(" User: 'actually focus on business impact'")
result2 = await session_mgr.chat_with_run_control(
agent, user_id, "actually focus on business impact"
)
print(f" Bot: {result2['response']}")
print(f" Metadata: {result2['metadata']}")
await asyncio.sleep(0.5)
print("\n3. User decides to stop current task:")
print(" User: '/stop'")
stop_response = await handle_stop_command(user_id, run_control)
print(f" Bot: {stop_response}")
# Wait for first task to complete (it should be cancelled)
try:
result1 = await task1
print(f"\n📊 Task 1 result: {result1['response']}")
print(f" Metadata: {result1['metadata']}")
except asyncio.CancelledError:
print("\n📊 Task 1 was cancelled as expected")
print("\n4. User sends fresh request after stopping:")
print(" User: 'what's the weather like?'")
result4 = await session_mgr.chat_with_run_control(
agent, user_id, "what's the weather like?"
)
print(f" Bot: {result4['response']}")
print("\n✅ Benefits of this approach:")
print(" - Immediate feedback on all messages")
print(" - Can cancel long-running tasks with /stop")
print(" - Pending messages are queued and processed in order")
print(" - User always knows what's happening")
async def demo_interrupt_mode():
"""Demonstrate interrupt mode."""
print("\n" + "="*60)
print("⚡ BONUS: Interrupt Mode (Cancel and Restart)")
print("="*60)
from praisonai.bots._run_control import SessionRunControl
from praisonai.bots._session import BotSessionManager
# Use interrupt mode instead of queue mode
run_control = SessionRunControl(busy_mode="interrupt")
session_mgr = BotSessionManager(run_control=run_control)
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User starts long task:")
print(" User: 'research renewable energy for 30 pages'")
task1 = asyncio.create_task(
session_mgr.chat_with_run_control(agent, user_id, "research renewable energy for 30 pages")
)
await asyncio.sleep(0.5)
print("\n2. User changes mind (interrupts and restarts):")
print(" User: 'actually just summarize solar panel efficiency'")
result2 = await session_mgr.chat_with_run_control(
agent, user_id, "actually just summarize solar panel efficiency"
)
print(f" Bot: {result2['response']}")
# Wait for original task (should be interrupted)
try:
result1 = await task1
print(f"\n📊 Original task result: {result1['response']}")
if result1['metadata'].get('interrupted'):
print(" ✅ Original task was properly interrupted")
except:
print("\n📊 Original task was cancelled")
print("\n✅ Interrupt mode is great for:")
print(" - When users frequently change direction")
print(" - Interactive sessions where latest intent matters most")
print(" - Real-time collaboration scenarios")
async def main():
"""Run all demonstrations."""
print("🚀 Bot In-Flight Run Control Demo")
print("Solving the silent lock problem described in issue #1914")
# Show the problem
await demo_without_run_control()
# Show the solution
await demo_with_run_control()
# Show advanced feature
await demo_interrupt_mode()
print("\n" + "="*60)
print("🎯 Summary")
print("="*60)
print("SessionRunControl solves the silent blocking problem by providing:")
print("1. Immediate feedback for mid-run messages")
print("2. /stop command support via InterruptController")
print("3. Configurable policies (queue/interrupt/steer)")
print("4. Run generation tracking to prevent race conditions")
print("5. Pending message merging and ordering")
print("\nThis makes bot interactions feel responsive and predictable!")
if __name__ == "__main__":
asyncio.run(main())
-201
View File
@@ -1,201 +0,0 @@
"""
Prototype implementation of Gateway Server with backpressure and flow control.
This demonstrates how the bounded queues and slow consumer detection would be
implemented in the wrapper layer (praisonai package) using the configuration
from the core SDK (praisonaiagents).
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
# For the prototype, we'll define minimal config inline
# from praisonaiagents.gateway import GatewayConfig
@dataclass
class SessionConfig:
max_inbox: int = 256
@dataclass
class GatewayConfig:
session_config: SessionConfig = field(default_factory=SessionConfig)
max_buffered_bytes: int = 1024 * 1024 # 1MB default
logger = logging.getLogger(__name__)
@dataclass
class GatewaySession:
"""Gateway session with bounded inbox for backpressure."""
_session_id: str
_agent_id: str
_client_id: Optional[str] = None
_inbox: asyncio.Queue = field(default_factory=asyncio.Queue)
_max_inbox: int = 256
_is_executing: bool = False
def setup_inbox(self, max_inbox: int = 256) -> None:
"""Set up the inbox with proper bounds."""
self._max_inbox = max_inbox
if max_inbox > 0:
self._inbox = asyncio.Queue(maxsize=max_inbox)
else:
self._inbox = asyncio.Queue() # Unbounded for backward compat if 0
async def queue_message(self, message: str) -> bool:
"""Queue a user message with backpressure handling.
Returns:
True if message was queued, False if inbox is full.
"""
if self._max_inbox > 0:
# Bounded queue - check if full
try:
self._inbox.put_nowait(message)
return True
except asyncio.QueueFull:
return False
else:
# Unbounded queue for backward compatibility
await self._inbox.put(message)
return True
class GatewayServer:
"""Gateway server with flow control."""
def __init__(self, config: GatewayConfig):
self.config = config
self._sessions: Dict[str, GatewaySession] = {}
self._clients: Dict[str, Any] = {} # client_id -> WebSocket
def create_session(self, session_id: str, agent_id: str, client_id: str) -> GatewaySession:
"""Create a new session with bounded inbox."""
session = GatewaySession(
_session_id=session_id,
_agent_id=agent_id,
_client_id=client_id,
)
# Set up inbox with configured bounds
session.setup_inbox(self.config.session_config.max_inbox)
self._sessions[session_id] = session
return session
async def handle_message(self, session: GatewaySession, message: str, client_id: str) -> str:
"""Handle incoming message with inbox overflow protection."""
# Try to queue the message
queued = await session.queue_message(message)
if not queued:
# Inbox is full, send error response
await self._send_to_client(
client_id,
{
"type": "error",
"code": "inbox_full",
"message": "Message queue is full. Please wait for current messages to be processed."
}
)
return "Inbox full - message rejected."
# Start processing if not already running
if not session._is_executing:
session._is_executing = True
asyncio.create_task(self._drain_session_queue(session, client_id))
return "Processing started."
return "Message queued."
async def _drain_session_queue(self, session: GatewaySession, client_id: str) -> None:
"""Process all messages from the session's inbox queue."""
try:
while not session._inbox.empty():
msg = await session._inbox.get()
# Process message here (simplified for prototype)
logger.info(f"Processing message for session {session._session_id}: {msg}")
# Simulate processing delay
await asyncio.sleep(0.1)
# Send response to client (simplified)
await self._send_to_client(
client_id,
{
"type": "response",
"message": f"Processed: {msg}"
}
)
except Exception as e:
logger.error(f"Error processing queue for session {session._session_id}: {e}")
finally:
session._is_executing = False
async def _send_to_client(self, client_id: str, data: Dict[str, Any]) -> None:
"""Send data to client with slow consumer detection."""
ws = self._clients.get(client_id)
if ws:
try:
# Check for slow consumer (buffered bytes)
if self.config.max_buffered_bytes > 0:
# Try to get transport buffer size
transport = getattr(ws, 'transport', None) or getattr(ws, '_transport', None)
if transport and hasattr(transport, 'get_write_buffer_size'):
buffered_bytes = transport.get_write_buffer_size()
if buffered_bytes > self.config.max_buffered_bytes:
# Check if this is a droppable event type
event_type = data.get("type", "")
droppable_types = {"presence", "typing", "status"}
if event_type in droppable_types:
# Drop the message for slow consumers
logger.warning(f"Dropping {event_type} event for slow consumer {client_id} (buffered: {buffered_bytes} bytes)")
return
else:
# Critical event - close the connection
logger.warning(f"Closing slow consumer {client_id} (buffered: {buffered_bytes} bytes > {self.config.max_buffered_bytes})")
await ws.close(code=1013, reason="slow consumer")
return
# Send the message
await ws.send_json(data)
except Exception as e:
logger.error(f"Error sending to client {client_id}: {e}", exc_info=True)
# Example usage demonstrating the flow control
async def demo():
"""Demonstrate the flow control implementation."""
# Create config with bounded inbox and slow consumer detection
config = GatewayConfig()
config.session_config.max_inbox = 10 # Small inbox for demo
config.max_buffered_bytes = 1024 # 1KB for demo
# Create server and session
server = GatewayServer(config)
session = server.create_session("sess1", "agent1", "client1")
# Simulate filling up the inbox
print(f"Inbox max size: {session._inbox.maxsize}")
# Fill the inbox
for i in range(10):
success = await session.queue_message(f"Message {i}")
print(f"Queued message {i}: {success}")
# Try to add one more - should fail
success = await session.queue_message("Overflow message")
print(f"Overflow message queued: {success}")
# Process messages
while not session._inbox.empty():
msg = await session._inbox.get()
print(f"Processing: {msg}")
if __name__ == "__main__":
asyncio.run(demo())
+11 -38
View File
@@ -4,70 +4,43 @@ Basic handoff example demonstrating agent-to-agent delegation.
This example shows how agents can hand off tasks to specialized agents.
"""
from pydantic import BaseModel
from typing import TypedDict, Optional
from praisonaiagents import Agent
# Define typed payload contracts for each handoff
class BillingPayload(BaseModel):
account_id: str
invoice_amount: Optional[float] = None
issue_type: str = "billing"
class RefundPayload(TypedDict):
transaction_id: str
amount: float
reason: str
class TechnicalPayload(BaseModel):
error_code: Optional[str] = None
device_info: str
user_agent: str
# Create specialized agents that expect typed payloads from the triage
# (In a real scenario, the agents would validate and use the payload.)
# Create specialized agents
billing_agent = Agent(
name="Billing Agent",
role="Billing Specialist",
goal="Handle all billing-related inquiries and tasks using structured data",
backstory="I am an expert in billing systems, payment processing, and invoice management.",
input_payload_schema=BillingPayload # hypothetical but consistent with design
goal="Handle all billing-related inquiries and tasks",
backstory="I am an expert in billing systems, payment processing, and invoice management."
)
refund_agent = Agent(
name="Refund Agent",
role="Refund Specialist",
goal="Process refund requests and handle refund-related issues with validated data",
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction.",
input_payload_schema=RefundPayload
goal="Process refund requests and handle refund-related issues",
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction."
)
technical_support_agent = Agent(
name="Technical Support",
role="Technical Support Specialist",
goal="Resolve technical issues and provide technical assistance with structured context",
backstory="I am skilled in troubleshooting technical problems and providing solutions.",
input_payload_schema=TechnicalPayload
goal="Resolve technical issues and provide technical assistance",
backstory="I am skilled in troubleshooting technical problems and providing solutions."
)
# Create a triage agent with typed handoffs
# Create a triage agent with handoffs to specialized agents
triage_agent = Agent(
name="Triage Agent",
role="Customer Service Triage",
goal="Understand customer needs and route them to the appropriate specialist with a validated payload",
goal="Understand customer needs and route them to the appropriate specialist",
backstory="I analyze customer requests and direct them to the most suitable specialist for efficient resolution.",
instructions="""Analyze the customer's request and determine which specialist can best help:
- For billing questions, payment issues, or invoices, transfer to the Billing Agent
- For refund requests or refund status inquiries, transfer to the Refund Agent
- For technical problems or product issues, transfer to Technical Support
Always explain why you're transferring the customer before doing so.
When handing off, populate the appropriate payload with extracted data.""",
handoffs=[
(billing_agent, BillingPayload),
(refund_agent, RefundPayload),
(technical_support_agent, TechnicalPayload)
]
Always explain why you're transferring the customer before doing so.""",
handoffs=[billing_agent, refund_agent, technical_support_agent]
)
# Example usage
@@ -179,7 +179,7 @@ def main():
context = memory.build_context_for_task(
task_descr="Explain AI alignment challenges",
memory={"user_id": "quality_demo_user"},
user_id="quality_demo_user",
max_items=3
)
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
JIRA Agent Watch Example
This example demonstrates how to use the JIRA watch tools with PraisonAI agents.
The agent can monitor JIRA issues and projects for changes, search for issues,
and get detailed issue information.
Setup:
1. Install JIRA library: pip install jira
2. Set environment variables:
- [email protected] (for cloud JIRA)
- JIRA_API_TOKEN=your_api_token
- Or use JIRA_USERNAME + JIRA_API_TOKEN for server JIRA
Usage:
python jira_agent_example.py
"""
import os
from praisonaiagents import Agent
from praisonaiagents.tools import jira_tools
def main():
"""Demonstrate JIRA agent watch capabilities."""
# Check if JIRA credentials are available
jira_email = os.getenv('JIRA_EMAIL')
jira_token = os.getenv('JIRA_API_TOKEN')
jira_username = os.getenv('JIRA_USERNAME')
if not jira_token:
print("❌ JIRA_API_TOKEN environment variable not set")
print("Please set your JIRA API token:")
print("export JIRA_API_TOKEN='your_token_here'")
return
if not (jira_email or jira_username):
print("❌ JIRA credentials incomplete")
print("Please set either:")
print("- JIRA_EMAIL (for cloud JIRA)")
print("- JIRA_USERNAME (for server JIRA)")
return
# Create agent with JIRA tools
agent = Agent(
name="JIRA Monitor Agent",
instructions="""
You are a JIRA monitoring agent. You can:
1. Watch specific JIRA issues for changes
2. Monitor JIRA projects for new issues and updates
3. Search for issues using JQL queries
4. Get detailed information about specific issues
When using JIRA tools, always provide the full JIRA URL
(e.g., https://yourcompany.atlassian.net).
Be helpful and provide clear summaries of JIRA activity.
""",
tools=jira_tools(), # Add all JIRA tools
llm="gpt-4o-mini"
)
print("🎯 JIRA Agent Watch Example")
print("=" * 50)
print()
print("Available JIRA tools:")
print("- jira_watch_issue: Monitor a specific issue for changes")
print("- jira_watch_project: Monitor a project for new/updated issues")
print("- jira_get_issue_info: Get detailed info about an issue")
print("- jira_search_issues: Search issues using JQL")
print()
# Example interactions
example_prompts = [
"Get information about JIRA issue DEMO-1 from https://praisonai.atlassian.net",
"Search for open issues in project DEMO from https://praisonai.atlassian.net using JQL: 'project = DEMO AND status = Open'",
"Watch JIRA issue DEMO-1 from https://praisonai.atlassian.net for 2 minutes (check every 60 seconds, max 2 checks)",
]
print("📝 Example prompts you can try:")
for i, prompt in enumerate(example_prompts, 1):
print(f"{i}. {prompt}")
print()
# Interactive mode
print("💬 Interactive JIRA Agent (type 'quit' to exit)")
print("-" * 50)
while True:
try:
user_input = input("\n🧑 You: ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
print("👋 Goodbye!")
break
if not user_input:
continue
print("🤖 Agent: ", end="", flush=True)
response = agent.start(user_input)
print(response)
except KeyboardInterrupt:
print("\n👋 Goodbye!")
break
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
main()
@@ -6,6 +6,7 @@ to different next steps based on output content.
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents import AgentFlowManager
# Create a workflow with branching
workflow = AgentFlow(
@@ -35,9 +36,16 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Decision Workflow"] = workflow
# Execute - will branch to success_handler since 42 is positive
result = manager.execute(
"Decision Workflow",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step in result.get("steps", []):
name = step.get("step", step.get("name", "unknown"))
status = step.get("status", "completed")
print(f" {name}: {status}")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
@@ -6,8 +6,9 @@ functions that return StepResult with stop_workflow=True.
"""
from praisonaiagents import (
AgentFlow, Task, WorkflowContext, StepResult
Workflow, Task, WorkflowContext, StepResult
)
from praisonaiagents import AgentFlowManager
# Custom validator that can stop the workflow
def validate_data(context: WorkflowContext) -> StepResult:
@@ -49,13 +50,20 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
manager = WorkflowManager()
manager.workflows["Data Processing"] = workflow
# Test with invalid data (will stop early)
print("=== Testing with invalid data ===")
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
for step in result.get("steps", []):
print(f" {step.get('step', 'step')}: {step.get('output', step.get('status'))}")
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['output']}")
# Test with valid data (will complete)
print("\n=== Testing with valid data ===")
workflow.variables["data"] = {"value": 42}
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
for step in result.get("steps", []):
print(f" {step.get('step', 'step')}: {step.get('status', 'completed')}")
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
+15 -8
View File
@@ -6,6 +6,7 @@ Each item is processed individually with the loop variable.
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents import AgentFlowManager
# Create a workflow with loop
workflow = AgentFlow(
@@ -29,12 +30,18 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Process Items"] = workflow
# Execute - will process each fruit in the list
result = manager.execute(
"Process Items",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step in result.get("steps", []):
name = step.get("step", step.get("name", "unknown"))
status = step.get("status", "completed")
print(f" {name}: {status}")
out = step.get("output")
if isinstance(out, list):
print(f" Loop processed {len(out)} items")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
if isinstance(step_result.get("output"), list):
print(f" Loop processed {len(step_result['output'])} items")
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
combining their results with an aggregator agent.
"""
from praisonaiagents import Agent, AgentFlow
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import parallel
# Create parallel research agents
@@ -5,7 +5,7 @@ Demonstrates decision-based routing where an agent classifier
routes to specialized handler agents based on the request type.
"""
from praisonaiagents import Agent, AgentFlow
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import route
# Create classifier agent
@@ -539,7 +539,7 @@ Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully
### When the PR has merge conflicts
If `mergeStateStatus=DIRTY`, the **Auto Merge Conflict → Claude** workflow (`.github/workflows/merge-conflict-claude.yml`) posts `@claude` automatically on push to `main` and on PR sync. You can still comment manually:
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."
@@ -755,20 +755,6 @@ Developer Push ─────────────────────
**Result**: `https://praison.ai/install.sh` is automatically updated **only when the source files change**!
### Auto-deploy when install scripts change (PraisonAI → praison.ai)
Pushing `install.sh` or `install.ps1` to PraisonAI **`main`** triggers [`.github/workflows/deploy-install-scripts.yml`](/Users/praison/praisonai-package/.github/workflows/deploy-install-scripts.yml), which dispatches the existing praison.ai **`CI`** workflow (`workflow_dispatch` on `master`). No manual website push or `make upgrade` is required.
```mermaid
flowchart LR
pushMain["PraisonAI push main\ninstall.sh|install.ps1"] --> deployWF["deploy-install-scripts.yml"]
deployWF --> dispatch["gh workflow run CI"]
dispatch --> siteCI["praison.ai azure-pipeline.yaml"]
siteCI --> live["https://praison.ai/install.sh"]
```
Manual re-deploy: `gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI`
### How install.sh is Served
The `web/install.sh` file is copied into the Docker image at `/var/www/html/web/install.sh` and served by nginx:
@@ -811,20 +797,20 @@ git push origin master
**Task 2: Update install.sh and deploy**
```bash
# 1. Update install.sh / install.ps1
# 1. Update install.sh
vim /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
# 2. Test install.sh
bash /Users/praison/praisonai-package/src/praisonai/scripts/test-install-smoke.sh
bash /Users/praison/praisonai-package/src/praisonai/scripts/install.sh --dry-run
# 3. Commit and push to PraisonAI main — auto-triggers praison.ai deploy
git add src/praisonai/scripts/install.sh src/praisonai/scripts/install.ps1
# 3. Commit and push
git add /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
git commit -m "fix: update install script"
git push origin main
# deploy-install-scripts.yml → praison.ai CI → https://praison.ai/install.sh
# Manual re-deploy (optional):
gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI
# 4. Deploy website (if needed)
cd /Users/praison/Sites/localhost/praisonai
make upgrade
```
**Task 3: Rollback deployment**
@@ -257,21 +257,6 @@ find $(python -c "import site; print(site.getsitepackages()[0])") -name "<suspic
---
## Fork PRs — contributor checklist and Claude push
For PRs from external forks (`head.repo != MervinPraison/PraisonAI`), ask the contributor:
1. **Allow edits from maintainers** on the PR (right sidebar).
2. **Keep fork and branch** until the PR is merged or closed.
Optional: private fork → add maintainer as collaborator; fork with Actions → only enable “Allow edits and access to secrets by maintainers” if they accept the risk.
When `maintainerCanModify` is true, `claude.yml` pushes via `GH_TOKEN` to the fork remote and `merge-conflict-claude.yml` may auto-trigger `@claude` for `DIRTY` PRs (after FINAL architecture review).
Manual unblock: `gh pr checkout <N> && git fetch origin main && git rebase origin/main && git push --force-with-lease`
---
## Quick Reference — Common Commands
| Action | Command |
+81 -59
View File
@@ -1,6 +1,6 @@
# Documentation Parity Tracker (Python)
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
> **Categories:** 60 | **Documented:** 57 | **Parity:** 95.0%
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
@@ -8,86 +8,108 @@ This report compares **Python SDK feature categories** against **Python document
| Metric | Count |
|--------|-------|
| Feature Categories | 66 |
| **Documented Categories** | **65** |
| **Undocumented Categories** | **1** |
| **Parity** | **98.5%** |
| Feature Categories | 60 |
| **Documented Categories** | **57** |
| **Undocumented Categories** | **3** |
| **Parity** | **95.0%** |
## Documented Categories
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 20 | 41 | 11111 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
| ✅ Approval | 1 | 3 | 1344 |
| ✅ Agent | 19 | 30 | 8367 |
| ✅ Agent-to-Agent (A2A) | 1 | 3 | 625 |
| ✅ Approval | 1 | 2 | 889 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 5 | 6 | 1637 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 23 | 7357 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 107 | 26699 |
| ✅ Chunking | 2 | 2 | 288 |
| ✅ Auto Generation | 3 | 6 | 1386 |
| ✅ Autonomy | 3 | 4 | 1146 |
| ✅ Bots | 5 | 2 | 573 |
| ✅ Chunking | 2 | 2 | 285 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 3551 |
| ✅ Conditions | 1 | 3 | 1085 |
| ✅ Configuration | 3 | 7 | 2160 |
| ✅ Context Management | 16 | 33 | 10280 |
| ✅ Database | 1 | 40 | 5373 |
| ✅ Deep Research | 8 | 2 | 437 |
| ✅ Code Execution | 3 | 13 | 3395 |
| ✅ Conditions | 1 | 3 | 1086 |
| ✅ Configuration | 2 | 7 | 1753 |
| ✅ Context Management | 13 | 29 | 7576 |
| ✅ Database | 1 | 31 | 3037 |
| ✅ Display | 6 | 2 | 1175 |
| ✅ Embeddings | 6 | 23 | 1966 |
| ✅ Evaluation | 1 | 6 | 2539 |
| ✅ Events | 1 | 2 | 764 |
| ✅ Execution | 3 | 3 | 1099 |
| ✅ Failover | 2 | 1 | 372 |
| ✅ Files | 2 | 6 | 1632 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 19 | 6767 |
| ✅ Guardrails | 4 | 4 | 1619 |
| ✅ Handoffs | 11 | 6 | 2499 |
| ✅ Hooks | 2 | 8 | 3146 |
| ✅ Evaluation | 1 | 6 | 2538 |
| ✅ Events | 1 | 2 | 699 |
| ✅ Execution | 3 | 2 | 515 |
| ✅ Files | 2 | 5 | 863 |
| ✅ Flow | 1 | 1 | 284 |
| ✅ Gateway | 6 | 1 | 256 |
| ✅ Guardrails | 4 | 4 | 2279 |
| ✅ Handoffs | 10 | 4 | 1674 |
| ✅ Hooks | 2 | 6 | 1606 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 4 | 23 | 4282 |
| ✅ LLM | 3 | 10 | 3674 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 1 | 51 | 10939 |
| ✅ Memory | 6 | 16 | 5879 |
| ✅ OCR | 2 | 1 | 226 |
| ✅ Observability | 2 | 23 | 2754 |
| ✅ Optimizer | 1 | 2 | 653 |
| ✅ Output | 3 | 4 | 860 |
| ✅ Parallel Execution | 3 | 2 | 399 |
| ✅ Planning | 6 | 5 | 1198 |
| ✅ Plugins | 8 | 3 | 1502 |
| ✅ Prompts | 2 | 7 | 1569 |
| ✅ Providers | 1 | 52 | 6214 |
| ✅ Knowledge | 3 | 12 | 3262 |
| ✅ LLM | 2 | 2 | 1049 |
| ✅ Loops | 4 | 1 | 250 |
| ✅ MCP | 2 | 47 | 9754 |
| ✅ Memory | 6 | 12 | 4474 |
| ✅ Output | 3 | 4 | 868 |
| ✅ Parallel Execution | 2 | 2 | 347 |
| ✅ Planning | 6 | 5 | 1506 |
| ✅ Plugins | 8 | 3 | 1494 |
| ✅ Prompts | 2 | 4 | 796 |
| ✅ Providers | 2 | 50 | 5396 |
| ✅ Query | 1 | 2 | 616 |
| ✅ RAG | 5 | 15 | 3190 |
| ✅ RAG | 5 | 14 | 2910 |
| ✅ Realtime | 2 | 5 | 584 |
| ✅ Reflection | 3 | 2 | 477 |
| ✅ Retrieval | 2 | 5 | 944 |
| ✅ Reflection | 3 | 2 | 469 |
| ✅ Retrieval | 2 | 5 | 941 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2478 |
| ✅ Security | 1 | 3 | 2550 |
| ✅ Sessions | 4 | 11 | 3445 |
| ✅ Skills | 6 | 10 | 3641 |
| ✅ Tasks | 2 | 6 | 3064 |
| ✅ Telemetry | 1 | 2 | 560 |
| ✅ Sandbox | 5 | 3 | 1002 |
| ✅ Security | 1 | 1 | 875 |
| ✅ Sessions | 2 | 8 | 2128 |
| ✅ Skills | 5 | 5 | 2002 |
| ✅ Tasks | 2 | 5 | 2590 |
| ✅ Telemetry | 1 | 2 | 541 |
| ✅ Templates | 1 | 7 | 1411 |
| ✅ Tools | 12 | 118 | 25896 |
| ✅ Tools | 4 | 97 | 17827 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Video | 2 | 5 | 460 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 3 | 6 | 1352 |
| ✅ Workflows | 4 | 14 | 5810 |
| ✅ Web | 4 | 6 | 1267 |
| ✅ Workflows | 4 | 9 | 3579 |
## Stub Documentation (Need Content)
These categories have documentation files but < 50 lines (stubs):
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ⚠️ OCR | 2 | 1 | 49 |
## Undocumented Categories (Need Documentation)
| Category | Features |
|----------|----------|
| ❌ Vector Store | 1 |
| ❌ Failover | 2 |
| ❌ Optimizer | 1 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ AI SDK (1 docs, 112 lines)
- ℹ️ Agent-to-User (A2U) (3 docs, 770 lines)
- ℹ️ CLI (91 docs, 21386 lines)
- ℹ️ Callbacks (1 docs, 985 lines)
- ℹ️ Chat (3 docs, 546 lines)
- ℹ️ Deep Research (2 docs, 437 lines)
- ℹ️ Deployment (29 docs, 5931 lines)
- ℹ️ Jobs (1 docs, 358 lines)
- ℹ️ Middleware (1 docs, 60 lines)
- ℹ️ Observability (21 docs, 1580 lines)
- ℹ️ Process (1 docs, 506 lines)
- ℹ️ Scheduler (2 docs, 858 lines)
- ℹ️ Storage (4 docs, 722 lines)
- ℹ️ Streaming (3 docs, 485 lines)
- ℹ️ Token Management (3 docs, 1020 lines)
- ℹ️ Vector Store (11 docs, 733 lines)
---
@@ -1,70 +1,35 @@
# PraisonAI Agents - Benchmark Results
**Generated:** 2026-06-17
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
**Generated:** 2025-12-18 14:40:22
**Iterations:** 100
**Test:** Agent instantiation (without tools)
## Agent Instantiation Time
## Results
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|-----------|---------------|----------|-------------|----------|
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
| Agno | 5.27 | — | — | 1.04x |
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
### PraisonAI detail (1000 iter)
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|---------|----------|----------|-------------|----------|
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
| Minimal, no tools | 13.40 | — | — | — |
## Historical baseline
| Source | Config | Avg (μs) | Notes |
|--------|--------|----------|-------|
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
| OpenAI Agents SDK | 5.26 | 1.39x |
| Agno | 5.64 | 1.49x |
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
| PydanticAI | 226.94 | 60.16x |
| LangGraph | 4,558.71 | 1,209x |
| CrewAI | 15,607.92 | 4,138x |
## Package Versions
| Package | Version |
|---------|---------|
| PraisonAI | 1.6.60 |
| Agno | 2.4.2 |
| OpenAI Agents SDK | 0.7.0 |
|---------|--------|
| PraisonAI | 0.1.5 |
| Agno | 2.3.14 |
| PydanticAI | 1.35.0 |
| OpenAI Agents SDK | 0.6.3 |
| LangGraph | 1.0.5 |
| CrewAI | 1.6.1 |
## How to Reproduce
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
import time, statistics
from typing import Literal
from praisonaiagents import Agent
def get_weather(city: Literal['nyc', 'sf']):
return 'cloudy' if city == 'nyc' else 'sunny'
def bench(fn, n=1000, w=10):
for _ in range(w): fn()
times = []
for _ in range(n):
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
"
```
Full multi-framework benchmark:
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
python benchmarks/simple_benchmark.py
```
@@ -1,62 +0,0 @@
# Agent Instantiation — Root Cause Analysis
**Date:** 2026-06-17
**Version:** praisonaiagents 1.6.58 (dev)
**Methodology:** 10 warmup + 1000 measured iterations, `time.perf_counter()`
## Summary
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
## Before vs After Fixes
| Config | Historical | Pre-fix | Post-fix |
|--------|------------|---------|----------|
| Minimal (`name`, `output="silent"`) | 3.20 μs | 31.36 μs | **16.35 μs** |
| `model="gpt-4o-mini"` | 3.77 μs | 32.19 μs | **16.86 μs** |
| `llm="gpt-4o-mini"` (deprecated) | 3.77 μs | 46.48 μs | **~18 μs** (warn once) |
| LiteLLM `openai/gpt-4o-mini` cold | 7.31 μs | **3.7 s** | **53.63 μs** |
| LiteLLM warmed instantiate | — | 60 μs | **19.83 μs** |
| Import `Agent` | — | 20.8 ms | **29.6 ms** |
## Root Causes (validated)
| ID | Cause | Status | Fix applied |
|----|-------|--------|-------------|
| H1 | Config loader cold start (+9 ms first init) | CONFIRMED | Fast path when no config defaults (`_defaults_has_any_values`) |
| H2 | LoopGuard on every init (~2 μs) | CONFIRMED | Lazy `_ensure_loop_guard()` on first tool call |
| H3 | Approval presets | REJECTED | No change needed |
| H4 | `llm=` deprecation every init (+16 μs) | CONFIRMED | Warn once per process in `warn_deprecated_param` |
| H5 | Init bloat / config resolution (~25 μs) | CONFIRMED | Partially addressed via loader fast path |
| H6 | LiteLLM eager `LLM()` import (+3.7 s) | CONFIRMED | Deferred `_llm_init_params` + lazy `llm_instance` property |
| H7 | `ExecutionConfig` deprecation every init | CONFIRMED | Fixed stack walk; internal SDK path cached |
## cProfile — Post-fix (500 warmed `model=` inits)
| Function | Time (ms) | Per call |
|----------|-----------|----------|
| `Agent.__init__` | 8 | ~16 μs |
| `apply_config_defaults` | 1 | ~2 μs |
| `ExecutionConfig.__post_init__` | 1 | ~2 μs (first call only walks stack) |
| `_warnings.warn` | **0** | removed from hot path |
## Fixes implemented
1. **`praisonaiagents/config/feature_configs.py`** — Fix `ExecutionConfig.__post_init__` deprecation guard; cache internal SDK detection.
2. **`praisonaiagents/utils/deprecation.py`** — Warn once per process for deprecated params.
3. **`praisonaiagents/config/loader.py`** — Skip `apply_config_defaults` lookups when config has no defaults.
4. **`praisonaiagents/agent/agent.py`** — Lazy LoopGuard, lazy LLM instance, deferred LiteLLM params.
5. **`benchmarks/simple_benchmark.py`** — Use `model=`, add 10-iteration warmup.
6. **`benchmarks/performance_benchmark.py`** — Use `model=` instead of deprecated `llm=`.
## Remaining gap (~16 μs vs ~3 μs)
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
## How to reproduce
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
```
@@ -120,8 +120,8 @@ def run_benchmark(iterations: int = 100) -> List[ParamResult]:
('backstory', 'I am an AI assistant.', 'backstory="..."'),
# LLM configuration
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
('model', 'openai/gpt-4o-mini', 'model="openai/gpt-4o-mini" (LiteLLM)'),
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
# Tools
('tools', [sample_tool], 'tools=[sample_tool]'),
@@ -298,7 +298,7 @@ def save_results(results: List[ParamResult], filename: str = 'PARAM_IMPACT_RESUL
f.write('### Best Practices\n\n')
f.write('1. Use `output="silent"` for production workloads\n')
f.write('2. Only enable `context=True` when needed for long conversations\n')
f.write('3. Use `model="gpt-4o-mini"` for fastest instantiation\n')
f.write('3. Use `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
f.write('4. Avoid `memory=True` unless persistent memory is required\n')
f.write('\n## How to Reproduce\n\n')
@@ -185,7 +185,7 @@ def instantiate_praisonai_agent():
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="gpt-4o-mini",
llm="gpt-4o-mini",
tools=tools,
output="silent"
)
@@ -196,7 +196,7 @@ def instantiate_praisonai_litellm_agent():
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="openai/gpt-4o-mini",
llm="openai/gpt-4o-mini",
tools=tools,
output="silent"
)
@@ -14,7 +14,6 @@ import argparse
ITERATIONS = 100
WARMUP = 10
def sample_tool(city: Literal['nyc', 'sf']):
@@ -24,10 +23,8 @@ def sample_tool(city: Literal['nyc', 'sf']):
return 'sunny'
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
def measure_instantiation(create_fn, iterations=ITERATIONS):
"""Measure average instantiation time in microseconds."""
for _ in range(warmup):
create_fn()
times = []
for _ in range(iterations):
start = time.perf_counter()
@@ -51,11 +48,11 @@ def run_benchmark():
from praisonaiagents import Agent as PraisonAgent
results['PraisonAI'] = measure_instantiation(
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
)
results['PraisonAI (LiteLLM)'] = measure_instantiation(
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
)
# Other frameworks for comparison
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""Example of using LLMConfig with Agent for model fallback configuration."""
from praisonaiagents import Agent
from praisonaiagents.config import LLMConfig
# Example 1: Pass LLMConfig via llm parameter
def example_llm_param():
"""Using LLMConfig via the llm parameter."""
config = LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="test-key"
)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
llm=config # Pass LLMConfig object directly
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}")
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
# Example 2: Pass LLMConfig via model parameter
def example_model_param():
"""Using LLMConfig via the model parameter (preferred)."""
config = LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="test-key"
)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
model=config # Pass LLMConfig via model parameter
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}")
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
# Example 3: Traditional usage still works
def example_traditional():
"""Traditional usage without LLMConfig (no fallback support)."""
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
model="gpt-4o",
base_url="https://api.example.com",
api_key="test-key"
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}") # Will be empty
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
if __name__ == "__main__":
print("=== Example 1: LLMConfig via llm parameter ===")
example_llm_param()
print()
print("=== Example 2: LLMConfig via model parameter ===")
example_model_param()
print()
print("=== Example 3: Traditional usage (no fallback) ===")
example_traditional()
@@ -114,9 +114,6 @@ def _get_lazy_cache():
# ============================================================================
_LAZY_IMPORTS = {
# Version information (lazy loaded to avoid file I/O on import)
'__version__': ('praisonaiagents._version', '__version__'),
# Tools (moved from eager imports for lazy loading)
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
@@ -178,11 +175,6 @@ _LAZY_IMPORTS = {
'loop': ('praisonaiagents.workflows', 'loop'),
'repeat': ('praisonaiagents.workflows', 'repeat'),
'when': ('praisonaiagents.workflows', 'when'),
'if_': ('praisonaiagents.workflows', 'if_'),
'include': ('praisonaiagents.workflows', 'include'),
'Include': ('praisonaiagents.workflows', 'Include'),
'MAX_NESTING_DEPTH': ('praisonaiagents.workflows', 'MAX_NESTING_DEPTH'),
'YAMLWorkflowParser': ('praisonaiagents.workflows', 'YAMLWorkflowParser'),
# Conditions (Protocol-driven condition evaluation)
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
@@ -237,7 +229,6 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
# Error hierarchy - structured exception handling
@@ -307,12 +298,6 @@ _LAZY_IMPORTS = {
# Knowledge
'Knowledge': ('praisonaiagents.knowledge.knowledge', 'Knowledge'),
'Chunking': ('praisonaiagents.knowledge.chunking', 'Chunking'),
'KnowledgeStoreProtocol': ('praisonaiagents.knowledge.protocols', 'KnowledgeStoreProtocol'),
'ScopeRequiredError': ('praisonaiagents.knowledge.protocols', 'ScopeRequiredError'),
'BackendNotAvailableError': ('praisonaiagents.knowledge.protocols', 'BackendNotAvailableError'),
'FileTracker': ('praisonaiagents.knowledge.indexing', 'FileTracker'),
'IndexResult': ('praisonaiagents.knowledge.indexing', 'IndexResult'),
'CorpusStats': ('praisonaiagents.knowledge.indexing', 'CorpusStats'),
# FastContext
'FastContext': ('praisonaiagents.context.fast', 'FastContext'),
@@ -336,24 +321,9 @@ _LAZY_IMPORTS = {
'SkillProperties': ('praisonaiagents.skills', 'SkillProperties'),
'SkillMetadata': ('praisonaiagents.skills', 'SkillMetadata'),
'SkillLoader': ('praisonaiagents.skills', 'SkillLoader'),
'validate': ('praisonaiagents.skills', 'validate'),
'validate_metadata': ('praisonaiagents.skills', 'validate_metadata'),
'discover_skills': ('praisonaiagents.skills', 'discover_skills'),
'load_skill': ('praisonaiagents.skills', 'load_skill'),
'EnforcementLevel': ('praisonaiagents.skills', 'EnforcementLevel'),
'SkillState': ('praisonaiagents.skills', 'SkillState'),
# Memory
'Memory': ('praisonaiagents.memory.memory', 'Memory'),
'AutoMemory': ('praisonaiagents.memory', 'AutoMemory'),
'ChromaMemory': ('praisonaiagents.memory.adapters.factories', 'create_chroma_memory_adapter'),
'register_memory_adapter': ('praisonaiagents.memory.adapters', 'register_memory_adapter'),
'register_memory_factory': ('praisonaiagents.memory.adapters', 'register_memory_factory'),
'get_memory_adapter': ('praisonaiagents.memory.adapters', 'get_memory_adapter'),
'list_memory_adapters': ('praisonaiagents.memory.adapters', 'list_memory_adapters'),
'add_memory_adapter': ('praisonaiagents.memory.adapters.registry', 'add_memory_adapter'),
'add_memory_factory': ('praisonaiagents.memory.adapters.registry', 'add_memory_factory'),
'has_memory_adapter': ('praisonaiagents.memory.adapters', 'has_memory_adapter'),
# Planning
'Plan': ('praisonaiagents.planning', 'Plan'),
@@ -503,7 +473,7 @@ _LAZY_IMPORTS = {
'GatewayClientProtocol': ('praisonaiagents.gateway.protocols', 'GatewayClientProtocol'),
'GatewayEvent': ('praisonaiagents.gateway.protocols', 'GatewayEvent'),
'GatewayMessage': ('praisonaiagents.gateway.protocols', 'GatewayMessage'),
'GatewayEventType': ('praisonaiagents.gateway.protocols', 'EventType'),
'EventType': ('praisonaiagents.gateway.protocols', 'EventType'),
'GatewayConfig': ('praisonaiagents.gateway.config', 'GatewayConfig'),
'SessionConfig': ('praisonaiagents.gateway.config', 'SessionConfig'),
@@ -805,12 +775,8 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
# ============================================================================
__all__ = [
# Version information
'__version__',
# Core classes - the essentials
'Agent',
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
'Agents', # Deprecated alias for AgentTeam (emits warning)
Whitespace-only changes.
@@ -111,3 +111,32 @@ def _get_plugins_list() -> list:
PLUGINS_ENABLED = _get_plugins_enabled()
PLUGINS_LIST = _get_plugins_list()
def missing_dependency_error(
package_name: str,
extra_name: Optional[str] = None,
feature_name: Optional[str] = None
) -> ImportError:
"""
Create a standardized ImportError for missing optional dependencies.
Args:
package_name: The name of the missing package (e.g., 'litellm')
extra_name: The pip extra to install (e.g., 'llm' for [llm])
feature_name: Human-readable feature name (e.g., 'LLM support')
Returns:
ImportError with helpful install instructions
"""
if extra_name:
install_cmd = f"pip install 'praisonaiagents[{extra_name}]'"
else:
install_cmd = f"pip install {package_name}"
feature_desc = f" for {feature_name}" if feature_name else ""
return ImportError(
f"{package_name} is required{feature_desc} but not installed. "
f"Please install with: {install_cmd}"
)
@@ -0,0 +1,49 @@
"""
Resolver helper functions for consolidated parameters.
These helpers wrap the canonical resolver for specific parameter types,
handling special cases like callable validators for guardrails.
"""
from typing import Any, Type
from .config.param_resolver import resolve, ArrayMode
from .config.presets import GUARDRAIL_PRESETS
def resolve_guardrails(value: Any, config_class: Type) -> Any:
"""
Resolve guardrails parameter.
Handles special case of callable validators which have highest precedence.
Args:
value: The guardrails parameter value
config_class: GuardrailConfig class
Returns:
Resolved config, callable, or string
"""
# Handle None
if value is None:
return None
# Handle False (disabled)
if value is False:
return None
# Handle callable (highest precedence after None check)
# This includes functions, lambdas, and callable objects
if callable(value) and not isinstance(value, type):
return value
# Use canonical resolver for everything else
return resolve(
value=value,
param_name="guardrails",
config_class=config_class,
presets=GUARDRAIL_PRESETS,
array_mode=ArrayMode.PRESET_OVERRIDE,
string_mode="llm_prompt",
default=None,
)
@@ -1,51 +0,0 @@
"""
Version utilities for praisonaiagents package.
This module provides version information by reading from pyproject.toml
to avoid duplication and ensure the single source of truth.
"""
import re
from pathlib import Path
def get_version() -> str:
"""
Get the version string from pyproject.toml.
Returns:
str: Version string (e.g., "1.6.52")
Raises:
RuntimeError: If version cannot be found or parsed
"""
try:
# Get the package root (praisonaiagents directory)
package_root = Path(__file__).parent
# Go up to src/praisonai-agents directory
pyproject_path = package_root.parent / "pyproject.toml"
if not pyproject_path.exists():
raise RuntimeError(f"pyproject.toml not found at {pyproject_path}")
content = pyproject_path.read_text()
# Look for version = "X.Y.Z" pattern
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if not match:
raise RuntimeError("Version not found in pyproject.toml")
return match.group(1)
except Exception as e:
# Fallback to "unknown" to avoid breaking imports
import warnings
warnings.warn(
f"Failed to read version from pyproject.toml: {e}. Using 'unknown'.",
RuntimeWarning
)
return "unknown"
# Cache the version to avoid reading the file multiple times
__version__ = get_version()
@@ -1,71 +0,0 @@
"""
Shared lazy-loaders for display utilities used across the Agent class and mixins.
These helpers defer importing rich and the heavy ``..main`` display module until
first use, which keeps import time low for silent mode and avoids circular
imports during package initialisation. Results are cached in module globals and
protected by per-resource locks using double-checked locking, so each import is
resolved at most once across threads. Independent resources use separate locks so
that a slow first import of one does not stall initialisation of the others.
"""
import threading
# Lazy-loaded modules (populated on first use, each protected by its own lock)
_console_lock = threading.Lock()
_live_lock = threading.Lock()
_main_module_lock = threading.Lock()
_rich_console = None
_rich_live = None
_main_module = None
def _get_console():
"""Lazy load rich.console.Console (thread-safe)."""
global _rich_console
if _rich_console is None:
with _console_lock:
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_live():
"""Lazy load rich.live.Live (thread-safe)."""
global _rich_live
if _rich_live is None:
with _live_lock:
if _rich_live is None:
from rich.live import Live
_rich_live = Live
return _rich_live
def _get_display_functions():
"""Lazy load display functions from main module (thread-safe)."""
global _main_module
if _main_module is None:
with _main_module_lock:
if _main_module is None:
from ..main import (
display_error,
display_instruction,
display_interaction,
display_generating,
display_self_reflection,
ReflectionOutput,
adisplay_instruction,
execute_sync_callback
)
_main_module = {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
return _main_module
File diff suppressed because it is too large. Load diff
@@ -13,8 +13,31 @@ import logging
from praisonaiagents._logging import get_logger
from ..errors import BudgetExceededError
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
logger = logging.getLogger(__name__)
@@ -64,41 +87,12 @@ Your Goal: {self.goal}"""
# Add memory context if memory is enabled
if self._memory_instance:
# Use cache-optimized context if model supports prompt caching and cache boundary is requested
use_cache_boundaries = hasattr(self, '_model_supports_prompt_caching') and self._model_supports_prompt_caching()
if use_cache_boundaries and hasattr(self._memory_instance, 'build_cache_optimized_context'):
try:
# Get the goal/task description for context building
task_desc = getattr(self, 'goal', '') or 'general assistance'
cache_result = self._memory_instance.build_cache_optimized_context(
task_descr=task_desc,
include_cache_boundary=True
)
memory_context = cache_result.get('stable_prefix', '')
cache_boundary = cache_result.get('cache_boundary', '')
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
if cache_boundary:
system_prompt += cache_boundary
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
except (AttributeError, KeyError, TypeError) as e:
# Fall back to standard memory context if cache optimization fails
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
else:
# Standard memory context without cache boundaries
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
# Add learn context if learn is enabled (auto-inject when memory="learn")
learn_context = self.get_learn_context()
@@ -112,60 +106,6 @@ Your Goal: {self.goal}"""
system_prompt += f"\n\n## Available Skills\n{skills_prompt}"
system_prompt += "\n\nWhen a skill is relevant to the task, read its SKILL.md file to get detailed instructions. If the skill has scripts in its scripts/ directory, you can execute them using the execute_code or run_script tool."
# Cache the base system prompt BEFORE adding session context
# Session context is per-turn and should not be cached
if cache_key:
self._cache_put(self._system_prompt_cache, cache_key, system_prompt)
# Add session context (platform awareness) if available - AFTER caching
try:
from ..session.context import get_session_context
session_ctx = get_session_context()
# Format session context into prompt if origin or targets are present
if session_ctx.origin or session_ctx.reachable_targets:
context_parts = []
# Add origin information
if session_ctx.origin:
origin = session_ctx.origin
origin_str = f"You are replying on {origin.platform}"
if origin.chat_type and origin.chat_type != "unknown":
origin_str += f" ({origin.chat_type}"
if origin.display_name:
origin_str += f' "{origin.display_name}"'
origin_str += ")"
if origin.thread_id:
origin_str += f" in thread {origin.thread_id}"
context_parts.append(origin_str + ".")
# Add reachable targets
if session_ctx.reachable_targets:
target_descriptions = []
for target in session_ctx.reachable_targets:
desc = f"{target.name}"
if target.kind == "home":
desc += f" ({target.platform}, home channel)"
elif target.kind == "alias":
desc += f" ({target.platform}:{target.channel_id})"
target_descriptions.append(desc)
if target_descriptions:
context_parts.append(
f"Reachable delivery targets: {', '.join(target_descriptions)}."
)
if context_parts:
system_prompt += "\n\n## Session Context\n" + "\n".join(context_parts)
except ImportError:
# Session context module not available, continue without it
pass
except Exception as e:
# Log unexpected errors but continue
import logging
logging.debug(f"Session context injection failed: {e}", exc_info=True)
pass
# Add tool usage instructions if tools are available
# Use provided tools or fall back to self.tools
tools_to_use = tools if tools is not None else self.tools
@@ -205,7 +145,10 @@ Your Goal: {self.goal}"""
except ImportError:
pass # Trust module not available, skip security instructions
# Note: Caching is done BEFORE session context injection to avoid cross-user leakage
# Cache the generated system prompt (only if cache_key is set, i.e., memory not enabled)
# Use LRU eviction to prevent unbounded growth
if cache_key:
self._cache_put(self._system_prompt_cache, cache_key, system_prompt)
return system_prompt
def _build_response_format(self, schema_model):
@@ -509,17 +452,6 @@ Your Goal: {self.goal}"""
else:
cleaned_tools.append(tool)
# Sort tools deterministically for cache stability
def sort_formatted_tools(tools_list):
"""Sort already-formatted tool schemas by function name."""
def sort_key(tool):
if isinstance(tool, dict) and tool.get('type') == 'function':
return str(tool.get('function', {}).get('name') or '')
return ''
return sorted(tools_list, key=sort_key)
cleaned_tools = sort_formatted_tools(cleaned_tools)
# Cache the formatted tools with LRU eviction, including tool search metadata
self._cache_put(
self._formatted_tools_cache,
@@ -937,22 +869,7 @@ Your Goal: {self.goal}"""
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
def _get_next_fallback_model(self, fallback_index):
"""Get the next fallback model from the chain, if available.
Args:
fallback_index: Current index in the fallback chain (0-based)
Returns:
str or None: Next model name if available, None otherwise
"""
if not hasattr(self, 'fallback_models') or not self.fallback_models:
return None
if fallback_index >= len(self.fallback_models):
return None
return self.fallback_models[fallback_index]
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
start_time = time.time()
# --- Proactive Context Budget Management (default-on) ---
@@ -1016,7 +933,7 @@ Your Goal: {self.goal}"""
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
final_response = self._chat_completion_with_retry(
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1037,11 +954,7 @@ Your Goal: {self.goal}"""
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception as e:
from ..errors import LLMError
# Don't retry if it's an LLMError that has exhausted retries
if isinstance(e, LLMError):
raise # Re-raise LLMErrors immediately to avoid double retry
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
@@ -1052,7 +965,7 @@ Your Goal: {self.goal}"""
# 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._chat_completion_with_retry(
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1199,8 +1112,7 @@ Your Goal: {self.goal}"""
return self._chat_completion(
truncated_messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_retry_depth=_retry_depth + 1
)
except Exception as compression_error:
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
@@ -1211,34 +1123,9 @@ Your Goal: {self.goal}"""
# Don't retry without actual credential rotation
elif classification.should_fallback_model:
# Try next model in the fallback chain
next_model = self._get_next_fallback_model(_fallback_index)
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
# TODO: Implement model fallback when available
logging.warning(f"[{self.name}] {classification.user_message} (model fallback not yet implemented)")
# Don't retry without actual model fallback
elif classification.is_retryable and classification.backoff_seconds > 0:
if _retry_depth < 2: # Limit retry attempts
@@ -1247,8 +1134,7 @@ Your Goal: {self.goal}"""
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_retry_depth=_retry_depth + 1
)
# Include remediation hints for unimplemented recovery actions
@@ -1256,10 +1142,7 @@ Your Goal: {self.goal}"""
if classification.should_rotate_credential:
user_message += " Credential rotation is not yet implemented."
if classification.should_fallback_model:
if not self.fallback_models:
user_message += " No fallback models configured."
elif _fallback_index >= len(self.fallback_models):
user_message += f" All {len(self.fallback_models)} fallback models exhausted."
user_message += " Model fallback is not yet implemented."
# Create LLMError with classification context
error = LLMError(
@@ -1297,8 +1180,7 @@ Your Goal: {self.goal}"""
response_format=None,
stream_callback=None,
emit_events=True,
_retry_depth=0,
_fallback_index=0
_retry_depth=0
):
"""
Handle LLM errors in async context using structured classification.
@@ -1363,7 +1245,7 @@ Your Goal: {self.goal}"""
return await self._handle_async_llm_error(
retry_error, truncated_messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index
stream_callback, emit_events, _retry_depth + 1
)
except Exception as compression_error:
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
@@ -1374,39 +1256,9 @@ Your Goal: {self.goal}"""
# Don't retry without actual credential rotation
elif classification.should_fallback_model:
# Try next model in the fallback chain
next_model = self._get_next_fallback_model(_fallback_index)
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
await asyncio.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events
)
except Exception as retry_error:
return await self._handle_async_llm_error(
retry_error, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
# TODO: Implement model fallback when available
logging.warning(f"[{self.name}] {classification.user_message} (model fallback not yet implemented)")
# Don't retry without actual model fallback
elif classification.is_retryable and classification.backoff_seconds > 0:
if _retry_depth < 2: # Limit retry attempts
@@ -1423,7 +1275,7 @@ Your Goal: {self.goal}"""
return await self._handle_async_llm_error(
retry_error, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index
stream_callback, emit_events, _retry_depth + 1
)
# Include remediation hints for unimplemented recovery actions
@@ -1431,10 +1283,7 @@ Your Goal: {self.goal}"""
if classification.should_rotate_credential:
user_message += " Credential rotation is not yet implemented."
if classification.should_fallback_model:
if not self.fallback_models:
user_message += " No fallback models configured."
elif _fallback_index >= len(self.fallback_models):
user_message += f" All {len(self.fallback_models)} fallback models exhausted."
user_message += " Model fallback is not yet implemented."
# Create LLMError with classification context
error = LLMError(
@@ -1478,7 +1327,7 @@ Your Goal: {self.goal}"""
"""
Execute unified chat completion using composition instead of runtime class mutation.
This method provides a unified dispatch for all LLM providers using
This method provides the same functionality as UnifiedChatMixin but uses
composition for safety and maintainability.
"""
from ..llm import create_llm_dispatcher
@@ -1602,7 +1451,7 @@ Your Goal: {self.goal}"""
"""
Execute unified async chat completion using composition instead of runtime class mutation.
This method provides a unified async dispatch for all LLM providers using
This method provides the same functionality as UnifiedChatMixin but uses
composition for safety and maintainability.
"""
from ..llm import create_llm_dispatcher
@@ -1987,7 +1836,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.warning(f"Context management error (continuing without): {e}")
return messages, None
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
def _truncate_tool_output(self, tool_name: str, output: str) -> str:
"""
Truncate tool output according to configured budget.
@@ -1996,7 +1845,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Args:
tool_name: Name of the tool
output: Raw tool output
tool_call_id: Optional ID for the tool call
Returns:
Truncated output if over budget, otherwise original
@@ -2005,8 +1853,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return output
try:
run_id = getattr(self, '_run_id', None)
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
return self.context_manager.truncate_tool_output(tool_name, output)
except Exception as e:
logging.warning(f"Tool output truncation error: {e}")
return output
@@ -2649,9 +2496,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# If not satisfactory and not at max reflections, continue with regeneration
logging.debug(f"{self.name} reflection count {reflection_count + 1}, continuing reflection process")
messages.append({"role": "user", "content": "Now regenerate your response using the reflection you made"})
# For reflection, always use non-streaming to ensure compatibility with sync adapters
# and to avoid streaming complexity during regeneration process
use_stream = False
# For custom LLMs during reflection, always use non-streaming to ensure complete responses
use_stream = self.stream if not self._using_custom_llm else False
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id)
content = response.choices[0].message.content
response_text = content.strip() if content else ""
@@ -2984,7 +2830,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
response_format = self._build_response_format(schema_model)
# Use composition instead of runtime class mutation for safety
response = await self._achat_completion_with_retry(
response = await self._execute_unified_achat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -3156,7 +3002,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
new_response = await self._achat_completion_with_retry(
new_response = await self._execute_unified_achat_completion(
messages=regenerate_messages,
temperature=temperature,
tools=formatted_tools,
@@ -4039,156 +3885,3 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
raise
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
return False
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
This method wraps the unified chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
from .retry_utils import jittered_backoff
from ..hooks import HookEvent, OnRetryInput
import time
max_attempts = retry_config.max_retries + 1
for attempt in range(max_attempts):
try:
# Call the underlying unified chat completion directly to avoid infinite recursion
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
except Exception as e:
from ..errors import LLMError
# Only retry LLMErrors that are marked as retryable
if not isinstance(e, LLMError) or not e.is_retryable:
raise # Re-raise non-retryable errors immediately
# If this is the last attempt, re-raise the error
if attempt >= max_attempts - 1:
raise
# Calculate delay for this retry attempt
delay = jittered_backoff(
attempt,
base_delay=retry_config.base_delay,
max_delay=retry_config.max_delay,
jitter_ratio=retry_config.jitter_ratio,
)
# Fire OnRetry hook with delay information
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
retry_count=attempt + 1,
max_retries=retry_config.max_retries,
error_message=str(e),
operation="llm_request",
delay_seconds=delay,
attempt=attempt
)
self._hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input)
# Log retry attempt (buffered to avoid spam during transient failures)
logger.debug(f"[{self.name}] Retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
# Sleep with interrupt awareness - make interruption terminal
interrupt_fn = getattr(self, '_is_interrupted', lambda: False)
sleep_start = time.time()
while time.time() - sleep_start < delay:
if interrupt_fn():
# Interruption is terminal - stop retrying
raise RuntimeError("Agent interrupted during retry backoff")
time.sleep(min(0.2, delay - (time.time() - sleep_start)))
# This should never be reached, but just in case
raise RuntimeError("Retry loop completed without returning or raising an exception")
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
This method wraps the async chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events
)
from .retry_utils import jittered_backoff, interruptible_sleep
from ..hooks import HookEvent, OnRetryInput
import time
import asyncio
max_attempts = retry_config.max_retries + 1
for attempt in range(max_attempts):
try:
# Call the underlying unified chat completion directly to avoid infinite recursion
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events
)
except Exception as e:
from ..errors import LLMError
# Only retry LLMErrors that are marked as retryable
if not isinstance(e, LLMError) or not e.is_retryable:
raise # Re-raise non-retryable errors immediately
# If this is the last attempt, re-raise the error
if attempt >= max_attempts - 1:
raise
# Calculate delay for this retry attempt
delay = jittered_backoff(
attempt,
base_delay=retry_config.base_delay,
max_delay=retry_config.max_delay,
jitter_ratio=retry_config.jitter_ratio,
)
# Fire OnRetry hook with delay information
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
retry_count=attempt + 1,
max_retries=retry_config.max_retries,
error_message=str(e),
operation="async_llm_request",
delay_seconds=delay,
attempt=attempt
)
await self._hook_runner.execute_async(HookEvent.ON_RETRY, retry_input)
# Log retry attempt
logger.debug(f"[{self.name}] Async retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
# Async sleep with interrupt awareness using the helper
interrupt_fn = getattr(self, '_is_interrupted', lambda: False)
if not await interruptible_sleep(delay, interrupt_fn=interrupt_fn):
raise RuntimeError("Agent interrupted during retry backoff")
# This should never be reached, but just in case
raise RuntimeError("Async retry loop completed without returning or raising an exception")
@@ -15,8 +15,31 @@ import asyncio
import threading
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
logger = logging.getLogger(__name__)
@@ -918,113 +941,6 @@ Write the complete compiled report:"""
self.verbose = original_verbose
self.markdown = original_markdown
def learn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Author a grounded, reusable skill from sources you describe.
Point the agent at real source material — a directory of code, API docs,
PDFs/manuals, configs, pasted notes, or "what we just did in this chat" —
and it gathers them with the tools it already has (file read, fetch, PDF
read) and authors **one grounded SKILL.md** via the ``skill_manage`` tool.
House-style rules keep self-written skills trustworthy: only flags, paths,
and APIs that appear verbatim in the source are used (never invented), the
SKILL.md is kept tight (~100-200 lines), and long material is moved to
supporting files referenced by relative path. The result is a permanent,
reusable skill, immediately invocable like any other skill.
This is the **skill authoring** path and is intentionally distinct from
the memory ``learn=`` constructor param (conversation→memory extraction)
and the automatic ``self_improve`` loop. It is a prompt + entry point on
top of existing tools, not a new distillation engine: it ensures
``skill_manage`` is available, then runs a single turn with :meth:`start`.
Args:
request: Natural-language description of the sources to learn from and
the skill to produce, e.g.
``"Read ./my-repo and the docs in ./docs/*.pdf and make a
'deploy-flow' skill"``.
**kwargs: Additional arguments forwarded to :meth:`start`.
Returns:
The agent's response (the authored skill summary), as returned by
:meth:`start`.
Example::
agent = Agent()
agent.learn_skill("Read ./my-repo and make a 'deploy-flow' skill")
"""
from praisonaiagents.skills import build_learn_prompt
self._ensure_skill_management_tools()
return self.start(build_learn_prompt(request), **kwargs)
async def alearn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Async variant of :meth:`learn_skill` (parity with ``start``/``astart``).
Ensures ``skill_manage`` is available, then awaits a single turn via
:meth:`astart` so async callers never block the event loop.
Args:
request: See :meth:`learn_skill`.
**kwargs: Additional arguments forwarded to :meth:`astart`.
Returns:
The agent's response (the authored skill summary).
"""
from praisonaiagents.skills import build_learn_prompt
self._ensure_skill_management_tools()
return await self.astart(build_learn_prompt(request), **kwargs)
def learn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Backward-compatible alias for :meth:`learn_skill`.
Retained so existing ``agent.learn("...")`` calls keep working. Prefer
:meth:`learn_skill` to avoid confusion with the memory ``learn=`` param.
"""
return self.learn_skill(request, **kwargs)
async def alearn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Backward-compatible async alias for :meth:`alearn_skill`."""
return await self.alearn_skill(request, **kwargs)
def _ensure_skill_management_tools(self) -> None:
"""Ensure the agent has the ``skill_manage`` tool for authoring skills.
Adds the skill-management tool (and lightweight file readers) to
``self.tools`` if they are not already present, so :meth:`learn` can
author a skill without the caller wiring tools manually. Idempotent.
"""
if not hasattr(self, 'tools') or self.tools is None:
self.tools = []
def _tool_id(tool: Any) -> str:
# Mirror tool_execution.py: handle callables, dict/JSON specs, and
# anything else via a stable string fallback so dedup never lets a
# dict-form spec slip past the callable form (or vice versa).
name = getattr(tool, '__name__', None)
if isinstance(tool, dict):
name = name or tool.get('name') or (tool.get('function') or {}).get('name')
return name or str(tool)
existing_names = {_tool_id(t) for t in self.tools}
try:
from praisonaiagents.tools.skill_tools import (
skill_manage,
read_skill_file,
list_skill_scripts,
)
except Exception as exc: # pragma: no cover - defensive
logger.debug(f"Could not import skill tools for learn_skill(): {exc}")
return
for tool in (skill_manage, read_skill_file, list_skill_scripts):
if _tool_id(tool) not in existing_names:
self.tools.append(tool)
existing_names.add(_tool_id(tool))
def execute(self, task: Any, context: Optional[Any] = None) -> Optional[str]:
"""Execute a task synchronously - backward compatibility method"""
if hasattr(task, 'description'):
@@ -405,59 +405,6 @@ class Handoff:
# In intersect mode, empty list means no tools allowed - this is the security boundary
return effective_tools
@staticmethod
def _extract_model_ref(agent: 'Agent') -> str:
"""Extract a model reference string from an agent configuration."""
llm = getattr(agent, 'llm', None) or getattr(agent, 'model', None)
if isinstance(llm, str):
return llm
if llm is not None:
for attr in ('model', 'model_name', 'model_ref'):
value = getattr(llm, attr, None)
if isinstance(value, str) and value:
return value
return 'gpt-4o-mini'
def _execute_with_runtime_resolution(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
logger.debug(
"Handoff to %s via agent.chat (model=%s, depth=%s)",
getattr(self.agent, 'name', 'unknown'),
target_model_ref,
_get_handoff_depth(),
)
return self.agent.chat(prompt, tools=effective_tools)
async def _execute_with_runtime_resolution_async(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full async chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
logger.debug(
"Async handoff to %s via agent chat (model=%s, depth=%s)",
getattr(self.agent, 'name', 'unknown'),
target_model_ref,
_get_handoff_depth(),
)
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
def _check_safety(self, source_agent: 'Agent') -> None:
"""
Check safety constraints before handoff.
@@ -622,10 +569,8 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Resolve runtime at turn-time instead of using construction-time pin
response = self._execute_with_runtime_resolution(
source_agent, full_prompt, effective_tools, kwargs
)
# Execute with tool boundary enforcement
response = self.agent.chat(full_prompt, tools=effective_tools)
result = HandoffResult(
success=True,
@@ -730,10 +675,13 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Resolve runtime at turn-time for async execution
response = await self._execute_with_runtime_resolution_async(
source_agent, full_prompt, effective_tools, kwargs
)
# Execute with tool boundary enforcement - check for async chat method
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt, tools=effective_tools)
else:
# Run sync chat in executor with tool constraint
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, lambda: self.agent.chat(full_prompt, tools=effective_tools))
result = HandoffResult(
success=True,
@@ -869,10 +817,7 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Resolve runtime at turn-time for tool function execution
response = self._execute_with_runtime_resolution(
source_agent, prompt, effective_tools, kwargs
)
response = self.agent.chat(prompt, tools=effective_tools)
result = HandoffResult(
success=True,
@@ -10,8 +10,31 @@ import os
import logging
from typing import Optional
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
logger = logging.getLogger(__name__)
@@ -294,9 +317,8 @@ class MemoryMixin:
return
try:
if self._session_store is None:
from ..session import get_default_session_store
self._session_store = get_default_session_store()
from ..session import get_default_session_store
self._session_store = get_default_session_store()
# Restore chat history from previous session
history = self._session_store.get_chat_history(self._session_id)
@@ -1,101 +0,0 @@
"""
Retry utilities with jittered exponential backoff for the PraisonAI SDK.
Provides interrupt-aware retry mechanisms for LLM API calls and tool execution
with configurable backoff strategies, jitter, and buffered status reporting.
"""
import asyncio
import random
import time
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass
class RetryBackoffConfig:
"""Configuration for jittered exponential backoff retry behavior."""
base_delay: float = 5.0 # Base delay in seconds
max_delay: float = 120.0 # Maximum delay in seconds
jitter_ratio: float = 0.5 # Jitter as fraction of delay added on top of base (0.0-1.0)
max_retries: int = 3 # Maximum retry attempts
def __post_init__(self):
"""Validate configuration parameters."""
if self.base_delay <= 0:
raise ValueError("base_delay must be > 0")
if self.max_delay < self.base_delay:
raise ValueError("max_delay must be >= base_delay")
if not (0 <= self.jitter_ratio <= 1):
raise ValueError("jitter_ratio must be between 0 and 1")
if self.max_retries < 0:
raise ValueError("max_retries must be >= 0")
def jittered_backoff(
attempt: int,
*,
base_delay: float = 5.0,
max_delay: float = 120.0,
jitter_ratio: float = 0.5,
) -> float:
"""
Calculate delay for jittered exponential backoff.
Args:
attempt: Current attempt number (0-based)
base_delay: Base delay in seconds
max_delay: Maximum delay cap in seconds
jitter_ratio: Jitter ratio (0.0-1.0), fraction of delay added as positive jitter
Returns:
Delay in seconds with jitter applied
Example:
>>> # Attempt 0: ~5s, Attempt 1: ~10s, Attempt 2: ~20s
>>> delay = jittered_backoff(1, base_delay=5.0, max_delay=120.0, jitter_ratio=0.5)
"""
# Exponential backoff: base * 2^attempt
delay = min(base_delay * (2 ** max(0, attempt)), max_delay)
# Apply jitter: delay + uniform(0, jitter_ratio * delay) for additive positive jitter
if jitter_ratio > 0:
jitter_range = delay * jitter_ratio
jitter = random.uniform(0, jitter_range) # Positive additive jitter only
delay = max(0.1, min(delay + jitter, max_delay)) # Clamp again after jitter
return delay
async def interruptible_sleep(
seconds: float,
check_interval: float = 0.2,
interrupt_fn: Optional[Callable[[], bool]] = None,
) -> bool:
"""
Sleep with periodic interruption checks.
Args:
seconds: Total sleep duration in seconds
check_interval: How often to check for interruption (seconds)
interrupt_fn: Function that returns True if sleep should be interrupted
Returns:
True if completed full sleep, False if interrupted
Example:
>>> interrupted = await interruptible_sleep(30.0, interrupt_fn=lambda: agent.is_stopped())
"""
if interrupt_fn is None:
interrupt_fn = lambda: False
elapsed = 0.0
while elapsed < seconds:
if interrupt_fn():
return False # Interrupted
sleep_time = min(check_interval, seconds - elapsed)
await asyncio.sleep(sleep_time)
elapsed += sleep_time
return True # Completed full sleep
@@ -5,10 +5,15 @@ Provides safe code execution capabilities to agents via the sandbox framework.
"""
import logging
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
from typing import Optional, Dict, Any, Union
if TYPE_CHECKING:
from ..sandbox import SandboxConfig, SandboxManager, SandboxResult
from ..sandbox import (
SandboxConfig,
SandboxManager,
SandboxResult,
check_code_safety,
format_warnings,
)
logger = logging.getLogger(__name__)
@@ -21,33 +26,27 @@ class SandboxMixin:
def __init__(self, *args, **kwargs):
# Extract sandbox config from kwargs
sandbox = kwargs.pop('sandbox', None)
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
# Convert boolean True to default config
if sandbox is True:
from ..sandbox import SandboxConfig
sandbox = SandboxConfig.subprocess()
elif sandbox is False:
sandbox = None
self.sandbox_config: Optional['SandboxConfig'] = sandbox
if self.sandbox_config is True:
self.sandbox_config = SandboxConfig.subprocess()
super().__init__(*args, **kwargs)
self._sandbox_manager: Optional['SandboxManager'] = None
self._sandbox_manager: Optional[SandboxManager] = None
@property
def has_sandbox(self) -> bool:
"""Check if sandbox is configured for this agent."""
return self.sandbox_config is not None
def get_sandbox_manager(self) -> Optional['SandboxManager']:
def get_sandbox_manager(self) -> Optional[SandboxManager]:
"""Get or create sandbox manager."""
if not self.has_sandbox:
return None
if self._sandbox_manager is None:
from ..sandbox import SandboxManager
self._sandbox_manager = SandboxManager(self.sandbox_config)
return self._sandbox_manager
@@ -58,7 +57,7 @@ class SandboxMixin:
language: str = "python",
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Execute code safely in configured sandbox.
Args:
@@ -80,7 +79,6 @@ class SandboxMixin:
# Security pre-check if enabled
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
@@ -99,7 +97,7 @@ class SandboxMixin:
language: str = "python",
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Synchronous wrapper for execute_code.
Args:
@@ -119,7 +117,7 @@ class SandboxMixin:
command: Union[str, list],
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Run a shell command in the sandbox.
Args:
@@ -141,7 +139,6 @@ class SandboxMixin:
# Security pre-check
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(command_str, "bash")
if warnings:
warning_text = format_warnings(warnings)
@@ -1,162 +0,0 @@
"""Autonomous skill self-improvement loop.
Closes the loop on the existing skill subsystem: after a task finishes, an
opt-in *guarded* review pass runs a short, isolated turn restricted to the
``skill_manage`` tool and asks the agent whether the session revealed a
reusable technique worth persisting as a skill.
Design guarantees (see issue #2231):
- Off by default — only runs when ``self_improve`` is enabled.
- Never runs with the full toolset — the review turn is restricted to the
single skill-mutation tool.
- Re-entrancy guarded — the review turn itself cannot trigger another review.
- Best-effort — any failure is swallowed so it never breaks the main task.
This logic lives in its own mixin so ``agent.py`` stays lean.
"""
import logging
logger = logging.getLogger(__name__)
class SkillReviewMixin:
"""Mixin providing the guarded skill-review pass for ``Agent``."""
def _skill_review_policy(self):
"""Return the active review policy, building the default lazily."""
policy = getattr(self, "_self_improve_policy", None)
if policy is not None:
return policy
from ..skills import DefaultSkillReviewPolicy
policy = DefaultSkillReviewPolicy()
self._self_improve_policy = policy
return policy
def _build_skill_review_tool(self):
"""Return the restricted toolset for the review turn (skill_manage only)."""
try:
from ..tools.skill_tools import create_skill_tools
skill_tools = create_skill_tools()
return [skill_tools.skill_manage]
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
return []
def _skill_review_log_context(self):
"""Return (agent_name, session_id) for swallowed-failure warnings."""
return (
getattr(self, "name", None),
getattr(self, "_session_id", None),
)
def _prepare_skill_review(self, prompt, response, tools_used=None):
"""Gate + build the review turn. Returns (review_prompt, review_tools) or None.
Shared by the sync and async paths so they stay in lock-step. Applies
the opt-in switch, the re-entrancy guard, the policy decision, and the
restricted-toolset build.
"""
# Opt-in only.
if not getattr(self, "_self_improve", False):
return None
# Re-entrancy guard: never review a review.
if getattr(self, "_in_skill_review", False):
return None
trajectory = {
"prompt": prompt if isinstance(prompt, str) else str(prompt),
"response": response or "",
"tools_used": list(tools_used or []),
}
try:
policy = self._skill_review_policy()
if not policy.should_review(trajectory):
return None
review_prompt = policy.review_prompt(trajectory)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review policy failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
return None
review_tools = self._build_skill_review_tool()
if not review_tools:
logger.debug("Skill review skipped: skill_manage tool unavailable")
return None
return review_prompt, review_tools
def _snapshot_chat_history_len(self):
"""Return the current chat_history length, or None if unavailable."""
history = getattr(self, "chat_history", None)
return len(history) if isinstance(history, list) else None
def _restore_chat_history(self, snapshot_len):
"""Trim chat_history back to ``snapshot_len`` so the review turn is isolated.
The review turn is internal: any user/assistant messages it appended to
the shared, persistent chat_history must not leak into subsequent turns
of a reused agent (chatbot, REPL, agentic loop).
"""
if snapshot_len is None:
return
history = getattr(self, "chat_history", None)
if isinstance(history, list) and len(history) > snapshot_len:
del history[snapshot_len:]
def _run_skill_review(self, prompt, response, tools_used=None):
"""Run the guarded skill-review pass.
Args:
prompt: The original user prompt for the finished task.
response: The final response produced for the task.
tools_used: Names of tools used during the task (if known).
"""
prepared = self._prepare_skill_review(prompt, response, tools_used)
if prepared is None:
return
review_prompt, review_tools = prepared
self._in_skill_review = True
history_len = self._snapshot_chat_history_len()
try:
if getattr(self, "verbose", False):
logger.info("Agent %s running guarded skill-review pass", self.name)
# Restricted turn: only skill_manage is exposed via the tools= arg.
self.chat(review_prompt, tools=review_tools)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review pass failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
finally:
self._restore_chat_history(history_len)
self._in_skill_review = False
async def _arun_skill_review(self, prompt, response, tools_used=None):
"""Async variant of :meth:`_run_skill_review`."""
prepared = self._prepare_skill_review(prompt, response, tools_used)
if prepared is None:
return
review_prompt, review_tools = prepared
self._in_skill_review = True
history_len = self._snapshot_chat_history_len()
try:
if getattr(self, "verbose", False):
logger.info("Agent %s running guarded skill-review pass", self.name)
await self.achat(review_prompt, tools=review_tools)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review pass failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
finally:
self._restore_chat_history(history_len)
self._in_skill_review = False
@@ -7,10 +7,21 @@ Reuses existing telemetry/token_collector for token tracking.
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Callable
from dataclasses import dataclass, field
logger = get_logger(__name__)
@dataclass
class SummarizationConfig:
"""Configuration for auto-summarization."""
enabled: bool = False
threshold: float = 0.8 # 80% of context window
context_window: int = 128000 # Default for GPT-4
summary_model: Optional[str] = None # Use smaller model for summaries
preserve_system: bool = True # Keep system message
preserve_recent: int = 2 # Keep last N message pairs
class SummarizationManager:
"""
Manages automatic conversation summarization.
@@ -168,3 +179,45 @@ SUMMARY:"""
"""Number of times summarization has been performed."""
return self._summarization_count
async def summarize_conversation(
messages: List[Dict[str, str]],
llm_call: Callable,
manager: SummarizationManager,
) -> List[Dict[str, str]]:
"""
Summarize conversation using LLM.
Args:
messages: Current message history
llm_call: Async function to call LLM
manager: SummarizationManager instance
Returns:
New message history with summary
"""
prompt = manager.generate_summary_prompt(messages)
# Call LLM to generate summary
summary = await llm_call(prompt)
# Prepare new history
new_history = manager.prepare_summarized_history(messages, summary)
# Reset token count (will be recalculated on next message)
manager.reset_tokens()
return new_history
def summarize_conversation_sync(
messages: List[Dict[str, str]],
llm_call: Callable,
manager: SummarizationManager,
) -> List[Dict[str, str]]:
"""
Synchronous version of summarize_conversation.
"""
prompt = manager.generate_summary_prompt(messages)
summary = llm_call(prompt)
new_history = manager.prepare_summarized_history(messages, summary)
manager.reset_tokens()
return new_history
@@ -1,300 +0,0 @@
"""
Integration tests for handoff runtime resolution.
This test suite validates that handoffs now use turn-time runtime resolution
instead of construction-time pins, addressing issue #1938.
"""
import pytest
import time
from unittest.mock import Mock, patch, MagicMock, AsyncMock
import asyncio
from .handoff import Handoff, HandoffConfig, ContextPolicy, HandoffResult
class MockAgent:
"""Mock agent for testing handoff scenarios."""
def __init__(self, name, model="gpt-4o", agent_id=None, session_id="test"):
self.name = name
self.llm = model
self.model = model
self.agent_id = agent_id or name
self._session_id = session_id
self.chat_history = []
def chat(self, prompt, **kwargs):
"""Mock chat method that returns a simple response."""
response = f"Response from {self.name} using {self.model}: {prompt[:50]}..."
self.chat_history.append({"role": "user", "content": prompt})
self.chat_history.append({"role": "assistant", "content": response})
return response
async def achat(self, prompt, **kwargs):
"""Mock async chat method."""
return self.chat(prompt, **kwargs)
class TestHandoffRuntimeResolution:
"""Test handoff runtime resolution functionality."""
def test_handoff_uses_turn_time_resolution(self):
"""Test that handoff uses turn-time runtime resolution instead of construction-time."""
# Create agents with different models
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4o")
# Create handoff
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.NONE)
)
# Mock the runtime resolution to verify it's called
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
mock_execute.return_value = "Resolved response"
# Execute programmatic handoff
result = handoff.execute_programmatic(
source_agent=source_agent,
prompt="Test handoff",
context={}
)
# Verify runtime resolution was called instead of direct agent.chat
mock_execute.assert_called_once()
args = mock_execute.call_args[0]
assert args[0] == source_agent # source_agent
assert "Test handoff" in args[1] # full_prompt with context
assert args[3] == {} # context kwargs
@pytest.mark.asyncio
async def test_async_handoff_uses_turn_time_resolution(self):
"""Test that async handoff uses turn-time runtime resolution."""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.NONE)
)
# Mock the async runtime resolution
with patch.object(handoff, '_execute_with_runtime_resolution_async') as mock_execute_async:
mock_execute_async.return_value = "Async resolved response"
# Execute async handoff
result = await handoff.execute_async(
source_agent=source_agent,
prompt="Test async handoff",
context={}
)
# Verify async runtime resolution was called
mock_execute_async.assert_called_once()
args = mock_execute_async.call_args[0]
assert args[0] == source_agent
assert "Test async handoff" in args[1]
def test_handoff_tool_function_uses_runtime_resolution(self):
"""Test that handoff tool function uses runtime resolution."""
source_agent = MockAgent("SourceAgent", "gpt-4")
source_agent.chat_history = [{"role": "user", "content": "Complete this task"}]
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.SUMMARY)
)
# Get tool function
tool_func = handoff.to_tool_function(source_agent)
# Mock the runtime resolution
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
mock_execute.return_value = "Tool resolved response"
# Execute tool function
result = tool_func(task="Complete this task")
# Verify runtime resolution was called
mock_execute.assert_called_once()
args = mock_execute.call_args[0]
assert args[0] == source_agent
@patch.object(MockAgent, 'chat')
def test_runtime_resolution_uses_agent_chat(self, mock_chat):
"""Handoff must execute via agent.chat, not a bare LLM runtime."""
mock_chat.return_value = "Agent chat response"
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(agent=target_agent)
response = handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test prompt",
effective_tools=[],
context={}
)
mock_chat.assert_called_once_with("Test prompt", tools=[])
assert response == "Agent chat response"
@pytest.mark.asyncio
async def test_async_runtime_resolution_uses_agent_achat(self):
"""Async handoff must execute via agent.achat, not a bare LLM runtime."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
with patch.object(target_agent, 'achat', new_callable=AsyncMock) as mock_achat:
mock_achat.return_value = "Async agent chat response"
handoff = Handoff(agent=target_agent)
response = await handoff._execute_with_runtime_resolution_async(
source_agent=source_agent,
prompt="Test async prompt",
effective_tools=[],
context={}
)
mock_achat.assert_called_once_with("Test async prompt", tools=[])
assert response == "Async agent chat response"
def test_runtime_resolution_uses_agent_chat_even_when_resolve_available(self):
"""Regression: bare resolve_runtime must not bypass agent instructions."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(agent=target_agent)
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
mock_runtime = Mock()
mock_runtime.execute.return_value = "Bare runtime response"
mock_resolve.return_value = mock_runtime
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
response = handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test fallback",
effective_tools=[],
context={}
)
mock_resolve.assert_not_called()
mock_runtime.execute.assert_not_called()
mock_chat.assert_called_once()
assert "Response from TargetAgent using gpt-4o" in response
@pytest.mark.asyncio
async def test_async_runtime_resolution_uses_agent_chat(self):
"""Async handoff should route through agent chat, not bare runtime."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "claude-3-haiku")
handoff = Handoff(agent=target_agent)
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
mock_resolve.side_effect = Exception("Should not be called")
response = await handoff._execute_with_runtime_resolution_async(
source_agent=source_agent,
prompt="Test async fallback",
effective_tools=[],
context={}
)
mock_resolve.assert_not_called()
assert "Response from TargetAgent using claude-3-haiku" in response
def test_extract_model_ref_from_llm_object(self):
"""Model extraction should handle LLM objects, not pass them to resolvers."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "gpt-4o")
llm_object = Mock()
llm_object.model = "claude-3-opus"
target_agent.llm = llm_object
handoff = Handoff(agent=target_agent)
assert handoff._extract_model_ref(target_agent) == "claude-3-opus"
class TestConstructionVsTurnTimeRegression:
"""
Regression tests for construction-time vs turn-time resolution.
These tests specifically validate that the fix for issue #1938 works correctly.
"""
def test_model_change_after_handoff_creation(self):
"""
Test the core regression case: model change after handoff creation.
Before fix: handoff would use construction-time model
After fix: handoff should use turn-time model
"""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4")
# Create handoff with initial target model
handoff = Handoff(agent=target_agent)
# Target agent changes model (router decision, user preference, etc.)
target_agent.llm = "gpt-4o"
target_agent.model = "gpt-4o"
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test with new model",
effective_tools=[],
context={}
)
mock_chat.assert_called_once()
assert handoff._extract_model_ref(target_agent) == "gpt-4o"
def test_sub_agent_tool_respects_current_model(self):
"""Test that as_tool() handoffs route through the target agent chat path."""
parent_agent = MockAgent("ParentAgent", "gpt-4")
parent_agent.chat_history = [{"role": "user", "content": "Complete subtask"}]
sub_agent = MockAgent("SubAgent", "claude-3-sonnet")
handoff = Handoff(agent=sub_agent)
sub_agent.llm = "claude-3-opus"
sub_agent.model = "claude-3-opus"
with patch.object(sub_agent, 'chat', wraps=sub_agent.chat) as mock_chat:
tool_func = handoff.to_tool_function(parent_agent)
result = tool_func(task="Complete subtask")
mock_chat.assert_called_once()
assert "claude-3-opus" in result
assert handoff._extract_model_ref(sub_agent) == "claude-3-opus"
def test_multiple_handoffs_different_models(self):
"""Test multiple handoffs with different models work correctly."""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_a = MockAgent("TargetA", "gpt-4o")
target_b = MockAgent("TargetB", "claude-3-sonnet")
handoff_a = Handoff(agent=target_a)
handoff_b = Handoff(agent=target_b)
response_a = handoff_a._execute_with_runtime_resolution(
source_agent, "Task A", [], {}
)
response_b = handoff_b._execute_with_runtime_resolution(
source_agent, "Task B", [], {}
)
assert "gpt-4o" in response_a
assert "claude-3-sonnet" in response_b
assert handoff_a._extract_model_ref(target_a) == "gpt-4o"
assert handoff_b._extract_model_ref(target_b) == "claude-3-sonnet"
# Run tests
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -14,7 +14,6 @@ import asyncio
import inspect
import contextvars
import concurrent.futures
import random
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
from ..errors import ToolExecutionError
from ..tools.trust import wrap_if_external
@@ -25,61 +24,8 @@ if TYPE_CHECKING:
pass
class BackoffPolicy:
"""Exponential backoff policy for tool retries."""
@staticmethod
def delay(attempt: int, initial_delay: float, backoff_factor: float, jitter: float, max_delay: float = 60.0) -> float:
"""Calculate delay for a retry attempt.
Args:
attempt: Attempt number (1-based)
initial_delay: Initial delay in seconds
backoff_factor: Exponential backoff multiplier
jitter: Fraction of base delay to add as random jitter
max_delay: Maximum delay to cap exponential growth
Returns:
Delay in seconds
"""
base = initial_delay * (backoff_factor ** (attempt - 1))
# Cap the base delay to prevent excessively long waits
base = min(base, max_delay)
jitter_amount = random.uniform(0, jitter * base)
return base + jitter_amount
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
def _register_artifact_tools(self):
"""Register artifact retrieval tools when artifacts are first created."""
try:
from ..tools import artifact_tools
# Set the store reference for the tools
artifact_tools.set_artifact_store(self._artifact_store)
# Add the retrieval tools
tools_to_add = [
artifact_tools.artifact_head,
artifact_tools.artifact_tail,
artifact_tools.artifact_grep,
artifact_tools.artifact_chunk,
artifact_tools.artifact_load,
artifact_tools.artifact_list,
]
# Only add if not already present
existing_tool_names = {getattr(t, '__name__', str(t)) for t in self.tools}
for tool in tools_to_add:
tool_name = getattr(tool, '__name__', str(tool))
if tool_name not in existing_tool_names:
self.tools.append(tool)
logging.debug("Registered artifact retrieval tools")
except Exception as e:
logging.warning(f"Failed to register artifact tools: {e}")
def _get_existing_stream_emitter(self):
"""Return an already-initialized stream emitter without creating one."""
@@ -285,10 +231,9 @@ class ToolExecutionMixin:
arguments.update(res.output.modified_data)
# Loop guard check - prevent tool execution loops with graduated response
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
if hasattr(self, '_loop_guard') and self._loop_guard:
from ..escalation.loop_guard import GuardAction
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
if decision.action == GuardAction.WARN:
# Inject warning into tool result so LLM sees guidance
@@ -300,6 +245,7 @@ class ToolExecutionMixin:
blocked_result = {"error": f"[loop-guard] {decision.message}", "loop_blocked": True}
elif decision.action == GuardAction.HALT:
# Halt execution with exception
from ..errors import ToolExecutionError
raise ToolExecutionError(
f"[loop-guard] {decision.message}",
tool_name=function_name,
@@ -327,177 +273,32 @@ class ToolExecutionMixin:
if blocked_result is not None:
result = blocked_result
else:
# Apply tool retry logic with exponential backoff
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
# Fall back to reading individual config attributes for backward compatibility
max_retry_limit = getattr(self, 'max_retry_limit', 2)
retry_initial_delay = 1.0
retry_backoff_factor = 2.0
retry_jitter = 0.1
else:
max_retry_limit = execution_config.max_retry_limit
retry_initial_delay = execution_config.retry_initial_delay
retry_backoff_factor = execution_config.retry_backoff_factor
retry_jitter = execution_config.retry_jitter
result = None
last_exception = None
# max_retry_limit is the number of retries (not total attempts)
# So total attempts = 1 (initial) + max_retry_limit (retries)
for attempt in range(1, max_retry_limit + 2):
try:
# 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)
# Check if the result indicates a retryable error
if isinstance(result, dict) and result.get("error"):
# Check if this is a circuit breaker error (always retryable)
if result.get("circuit_open"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# Check if this is a timeout error (retryable)
elif result.get("timeout"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# For other error dicts: approval/permission denials are legitimate
# non-retryable outcomes; everything else represents a tool failure
# that should engage the outer retry/backoff loop.
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error"):
break
else:
# Avoid compounding with the inner retry loop in
# _execute_tool_with_circuit_breaker: error types it already
# retries (e.g. timeout/rate_limit/connection_error) are
# exhausted by the time they reach here, so escalating them as
# retryable would re-run the entire inner loop from scratch on
# every outer attempt. Only escalate error types the inner loop
# does NOT retry (e.g. "unknown") so they get one outer pass.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
is_retryable = error_type not in inner_policy.retry_on
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
else:
# Success path - return the result
break
except ToolExecutionError as e:
last_exception = e
# Only retry if the error is marked as retryable and we have retries left
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
raise e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
except Exception as e:
# Wrap unexpected exceptions in ToolExecutionError
# Most tool errors are considered retryable unless they're programming errors
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
tool_error = ToolExecutionError(
f"Tool '{function_name}' failed: {e}",
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
last_exception = tool_error
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not is_retryable or (attempt - 1) >= max_retry_limit:
raise tool_error from e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
# Apply runtime-scoped middleware normalization BEFORE hooks fire
# Plugin harnesses can register middleware to normalize vendor-specific results
runtime_id = getattr(self, '_runtime_id', 'praisonai') # Default to native runtime
normalized_result = None # Track normalized result for hooks
if runtime_id != 'praisonai': # Skip for native runtime to avoid allocation
try:
from ..runtime import get_middleware, MiddlewareContext
middleware = get_middleware(runtime_id)
# 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()
# Only allocate context and normalize if not using default pass-through
if middleware.runtime_id != 'praisonai':
mw_ctx = MiddlewareContext(
tool_name=function_name,
runtime_id=runtime_id,
agent_id=self.name,
session_id=getattr(self, '_session_id', None),
execution_time_ms=(_time.time() - _tool_start_time) * 1000,
metadata={'original_result_type': type(result).__name__}
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}"
)
normalized_result = middleware.normalize(result, function_name, mw_ctx)
# Handle error cases by propagating error message as result
if not normalized_result.success and normalized_result.error_message:
# For failed tools, include error context in the result
result = f"Tool Error: {normalized_result.error_message}"
else:
# For successful tools, use the normalized content
result = normalized_result.content
# Store normalized result for hooks to access full context
self._last_normalized_result = normalized_result
logger.debug(f"Applied runtime middleware for {runtime_id}: {function_name}")
except ImportError:
# Runtime middleware not available - continue without normalization
logger.debug("Runtime middleware not available, skipping normalization")
except Exception as e:
# Don't let middleware failures break tool execution
logger.warning(f"Runtime middleware failed for {runtime_id}: {e}")
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 prompt injection protection for external tools
# Zero-cost for trusted tools, wraps external content in security markers
@@ -509,78 +310,28 @@ class ToolExecutionMixin:
try:
result_str = str(result)
# Get configured limit
limit = getattr(self, 'tool_output_limit', 16000)
# Check if we need to spill to artifact store
if len(result_str) > limit:
# Try to use artifact store if available
artifact_ref = None
if hasattr(self, '_artifact_store') and self._artifact_store is not None:
try:
from ..context.artifacts import ArtifactMetadata
# Create metadata for this artifact
metadata = ArtifactMetadata(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
tool_name=function_name,
turn_id=getattr(self, '_turn_counter', 0),
)
# Store the full output
artifact_ref = self._artifact_store.store(result_str, metadata)
logging.debug(f"Stored {function_name} output ({len(result_str)} bytes) as artifact {artifact_ref.artifact_id}")
# Register artifact retrieval tools if not already registered
if not hasattr(self, '_artifact_tools_registered'):
self._register_artifact_tools()
self._artifact_tools_registered = True
except Exception as e:
logging.debug(f"Failed to store artifact: {e}")
# Generate truncated preview
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
# If we stored an artifact, include reference in the output
if artifact_ref:
truncated = (
f"{head}\n"
f"...[{len(result_str):,} chars total, showing first/last portions]...\n"
f"{tail}\n\n"
f"{artifact_ref.to_inline()}"
)
else:
# Fallback to simple truncation
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
if self.context_manager:
# Use context-aware truncation with configured budget
truncated = self._truncate_tool_output(function_name, result_str)
else:
truncated = result_str
if self.context_manager and hasattr(self, '_truncate_tool_output'):
# Use context-aware truncation if available, but preserve artifact reference
if 'artifact_ref' in locals() and artifact_ref:
# Extract the artifact reference from the truncated string
artifact_inline = artifact_ref.to_inline()
# Remove the artifact reference before context truncation
truncated_without_ref = truncated.replace(artifact_inline, "").rstrip()
# Apply context truncation
truncated_without_ref = self._truncate_tool_output(function_name, truncated_without_ref, tool_call_id)
# Re-append the artifact reference
truncated = f"{truncated_without_ref}\n\n{artifact_inline}"
# Apply default limit even without context management
# This prevents runaway tool outputs from causing overflow
limit = getattr(self, 'tool_output_limit', 16000)
if len(result_str) > limit:
# Use smart truncation format that judge recognizes as OK
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
else:
truncated = self._truncate_tool_output(function_name, truncated, tool_call_id)
truncated = result_str
if len(truncated) < len(result_str):
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
# For dicts, truncate large string fields (e.g., raw_content from search)
if isinstance(result, dict):
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
# Add artifact reference to dict result if available
if 'artifact_ref' in locals() and artifact_ref:
result["_artifact_ref"] = artifact_ref.to_dict()
result = self._truncate_dict_fields(result, function_name, max_field_chars)
else:
result = truncated
except Exception as e:
@@ -622,15 +373,6 @@ class ToolExecutionMixin:
# Trigger AFTER_TOOL hook
from ..hooks import HookEvent, AfterToolInput
# Extract error information from normalized result if available
tool_error = None
if hasattr(self, '_last_normalized_result') and self._last_normalized_result:
if not self._last_normalized_result.success and self._last_normalized_result.error_message:
tool_error = self._last_normalized_result.error_message
# Clean up temporary attribute
delattr(self, '_last_normalized_result')
after_tool_input = AfterToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
@@ -640,7 +382,6 @@ class ToolExecutionMixin:
tool_name=function_name,
tool_input=arguments,
tool_output=result,
tool_error=tool_error,
execution_time_ms=(_time.time() - _tool_start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
@@ -653,12 +394,11 @@ class ToolExecutionMixin:
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
# Record tool execution in loop guard
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
if hasattr(self, '_loop_guard') and self._loop_guard:
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
loop_guard.record(function_name, arguments, is_success)
self._loop_guard.record(function_name, arguments, is_success)
# Handle warning injection for WARN decisions
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
if decision.action.value == "warn":
if isinstance(result, str):
result = f"{result}\n\n[loop-guard] {decision.message}"
@@ -677,29 +417,25 @@ class ToolExecutionMixin:
return result
except Exception as e:
# Emit tool call end with error for exceptions that escape the retry loop
# Emit tool call end with error
_duration_ms = (_time.time() - _tool_start_time) * 1000
_trace_emitter.tool_call_end(self.name, function_name, None, _duration_ms, str(e))
# Wrap the exception if it's not already a ToolExecutionError
if not isinstance(e, ToolExecutionError):
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
raise # Re-raise if already wrapped
# Preserve existing ToolExecutionError unchanged to maintain loop guard HALT behavior
if isinstance(e, ToolExecutionError):
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."""
# During a guarded skill-review turn, skip the entire after-agent
# side-effect pipeline (hooks, auto-memory, auto-learning, nudge,
# skill-review). The review turn is internal and must not re-fire
# these effects or recurse into another review.
if getattr(self, "_in_skill_review", False):
return response
from ..hooks import HookEvent, AfterAgentInput
after_agent_input = AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
@@ -735,31 +471,10 @@ class ToolExecutionMixin:
# Log learning nudge failures for debugging
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
# Runs a guarded review pass restricted to skill_manage. No-op when
# disabled or already inside a review (re-entrancy guarded).
try:
run_review = getattr(self, "_run_skill_review", None)
if run_review is not None:
run_review(prompt, response, tools_used)
except Exception as e:
logger.warning(
"Skill self-improvement review failed for agent=%s session_id=%s; "
"set self_improve=False to disable this best-effort pass. error=%s",
getattr(self, "name", None),
getattr(self, "_session_id", None),
e,
exc_info=True,
)
return response
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
"""Async version: Trigger AFTER_AGENT hook and return response."""
# During a guarded skill-review turn, skip the entire after-agent
# side-effect pipeline (see sync variant for rationale).
if getattr(self, "_in_skill_review", False):
return response
from ..hooks import HookEvent, AfterAgentInput
after_agent_input = AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
@@ -792,21 +507,6 @@ class ToolExecutionMixin:
# Log learning nudge failures for debugging
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
try:
arun_review = getattr(self, "_arun_skill_review", None)
if arun_review is not None:
await arun_review(prompt, response, tools_used)
except Exception as e:
logger.warning(
"Skill self-improvement review failed for agent=%s session_id=%s; "
"set self_improve=False to disable this best-effort pass. error=%s",
getattr(self, "name", None),
getattr(self, "_session_id", None),
e,
exc_info=True,
)
return response
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
@@ -831,7 +531,7 @@ class ToolExecutionMixin:
response=response,
)
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = None) -> dict:
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None) -> dict:
"""Truncate large string fields in a dict to prevent context overflow."""
if max_field_chars is None:
# Use tool budget from context manager (default 5000 tokens * 4 chars/token = 20000 chars)
@@ -846,30 +546,13 @@ class ToolExecutionMixin:
tail_limit = int(max_field_chars * 0.15)
head = value[:head_limit]
tail = value[-tail_limit:] if tail_limit > 0 else ""
truncated = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
# Store full field value for later retrieval
try:
from ..runtime.tool_output_store import get_tool_output_store
from uuid import uuid4
store = get_tool_output_store(getattr(self, '_run_id', None))
# Add unique suffix to prevent collisions with repeated keys
unique_suffix = uuid4().hex[:8]
field_call_id = f"{tool_call_id}_{key}_{unique_suffix}" if tool_call_id else f"{tool_name}_{key}_{unique_suffix}"
metadata = store.store(f"{tool_name}.{key}", value, call_id=field_call_id)
if metadata:
truncated = store.format_reference(metadata, truncated)
logging.debug(f"Stored full {tool_name}.{key} field ({len(value)} bytes) at {metadata.get('path')}")
except Exception as e:
logging.debug(f"Failed to store dict field: {e}")
result[key] = truncated
result[key] = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
logging.debug(f"Smart truncated field '{key}' from {len(value)} to ~{max_field_chars} chars")
elif isinstance(value, dict):
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars, tool_call_id)
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars)
elif isinstance(value, list):
result[key] = [
self._truncate_dict_fields(item, tool_name, max_field_chars, tool_call_id) if isinstance(item, dict)
self._truncate_dict_fields(item, tool_name, max_field_chars) if isinstance(item, dict)
else (self._smart_truncate_str(item, max_field_chars) if isinstance(item, str) and len(item) > max_field_chars else item)
for item in value
]
@@ -901,22 +584,12 @@ class ToolExecutionMixin:
from ..approval import get_approval_registry
from ..approval.protocols import ApprovalRequest, ApprovalDecision
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
from ..tools import get_registry as get_tool_registry
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
# Check tool trust level if available
tool_registry = get_tool_registry()
trust_level = tool_registry.get_trust_level(tool_name)
if backend is not None:
# Check if tool needs approval based on multiple criteria
needs_approval = (
approve_all
or tool_name in DEFAULT_DANGEROUS_TOOLS
or (trust_level == "external") # External tools need approval
)
needs_approval = approve_all or tool_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=tool_name,
@@ -0,0 +1,152 @@
"""
Unified Chat Mixin for Agent class.
This module consolidates the dual execution paths (custom LLM vs OpenAI)
into a single async-first protocol-driven implementation, eliminating
the DRY violation and maintenance burden described in Issue #1304.
"""
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional, Union
from ..llm import create_llm_dispatcher, UnifiedLLMDispatcher
class UnifiedChatMixin:
"""
Unified chat implementation that replaces dual execution paths.
This mixin consolidates:
- Path A: LLM.get_response() / LLM.get_response_async()
- Path B: OpenAIClient chat completion
Into a single async-first protocol-driven dispatch using UnifiedLLMDispatcher.
"""
def _get_unified_dispatcher(self) -> UnifiedLLMDispatcher:
"""
Get or create the unified LLM dispatcher for this agent.
This method implements the provider selection logic that was
previously scattered in _chat_completion() method.
"""
if hasattr(self, '_unified_dispatcher') and self._unified_dispatcher is not None:
return self._unified_dispatcher
# Determine which adapter to use based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
# Use LiteLLM adapter for custom LLM instances
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
else:
# Use OpenAI adapter for direct OpenAI access
if not hasattr(self, '_openai_client') or self._openai_client is None:
# Initialize OpenAI client if not present
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
return dispatcher
def _unified_chat_completion(
self,
messages: List[Dict[str, Any]],
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream: bool = True,
reasoning_steps: bool = False,
task_name: Optional[str] = None,
task_description: Optional[str] = None,
task_id: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None
) -> Any:
"""
Unified sync chat completion method.
This replaces the existing _chat_completion() method's dual dispatch logic
with a single path through UnifiedLLMDispatcher.
"""
start_time = time.time()
# Get unified dispatcher (handles provider selection)
dispatcher = self._get_unified_dispatcher()
# Use the same message processing logic but route through unified dispatcher
formatted_tools = self._format_tools_for_completion(tools)
try:
# Single dispatch path for all providers
final_response = dispatcher.chat_completion(
messages=messages,
tools=formatted_tools,
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
**{
'reasoning_steps': reasoning_steps,
'task_name': task_name,
'task_description': task_description,
'task_id': task_id,
}
)
return final_response
except Exception as e:
logging.error(f"Unified chat completion failed: {e}")
# Apply the same error handling as the original implementation
raise
async def _unified_achat_completion(
self,
messages: List[Dict[str, Any]],
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream: bool = True,
reasoning_steps: bool = False,
task_name: Optional[str] = None,
task_description: Optional[str] = None,
task_id: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None
) -> Any:
"""
Unified async chat completion method.
This provides native async implementation, eliminating the need
for separate async/sync implementations in the LLM layer.
"""
start_time = time.time()
# Get unified dispatcher (handles provider selection)
dispatcher = self._get_unified_dispatcher()
# Use the same message processing logic but route through unified dispatcher
formatted_tools = self._format_tools_for_completion(tools)
try:
# Single async dispatch path for all providers
final_response = await dispatcher.achat_completion(
messages=messages,
tools=formatted_tools,
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
**{
'reasoning_steps': reasoning_steps,
'task_name': task_name,
'task_description': task_description,
'task_id': task_id,
}
)
return final_response
except Exception as e:
logging.error(f"Unified async chat completion failed: {e}")
# Apply the same error handling as the original implementation
raise
@@ -34,22 +34,6 @@ class UnifiedExecutionMixin:
This replaces the duplicated logic between chat/achat and execute_tool/execute_tool_async
with a single async-first implementation plus sync bridge.
"""
def _resolve_cli_backend_at_turn_time(self):
"""
Resolve CLI backend at turn-time instead of construction time.
This allows for runtime-specific backend selection based on model changes
or other runtime conditions.
Returns:
The resolved CLI backend instance, or current backend if no override needed.
"""
# For now, this is a placeholder for future backend override logic
# The runtime resolution infrastructure is in place, but the policy
# decisions about when to switch backends are not yet implemented
# Always return the current backend until override logic is implemented
return getattr(self, '_cli_backend', None)
async def _unified_chat_impl(
self,
@@ -94,38 +78,12 @@ class UnifiedExecutionMixin:
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
# Reset loop guard for new chat turn (always-on protection)
if hasattr(self, '_ensure_loop_guard'):
self._ensure_loop_guard().reset_turn()
if hasattr(self, '_loop_guard') and self._loop_guard:
self._loop_guard.reset_turn()
try:
# Runtime resolution - check for model-scoped runtime before legacy CLI backend
runtime_instance = None
if getattr(self, '_runtime_config', None) is not None:
runtime_instance = await self._resolve_turn_runtime()
# Model-scoped runtime routing - delegate entire turn to resolved runtime
if runtime_instance is not None:
return await self._chat_via_runtime(
runtime_instance=runtime_instance,
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
)
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
# CLI Backend routing - delegate entire turn if configured
# Note: Turn-time resolution placeholder is in place for future backend switching
# 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,
@@ -40,21 +40,6 @@ class TaskStatus(Enum):
# Set up logger
logger = get_logger(__name__)
def _launch_auth_token() -> Optional[str]:
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def _authorise_launch_request(request) -> bool:
token = _launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:] == token:
return True
return request.headers.get("X-Auth-Token") == token
# Agent server registry for thread-safe server management
@@ -260,20 +245,7 @@ Expected Output: {task.expected_output}."""
# Add memory context if available
if task.memory:
try:
# Use cache-optimized context if available for better prompt caching
if hasattr(task.memory, 'build_cache_optimized_context'):
try:
cache_result = task.memory.build_cache_optimized_context(
task_descr=task.description,
include_cache_boundary=False # Don't include boundary for agent task context
)
memory_context = cache_result.get('stable_prefix', '')
except Exception as e:
# Fall back to standard context building
logger.debug(f"Cache-optimized context failed for task '{task.description or task.id}', falling back to standard: {e}")
memory_context = task.memory.build_context_for_task(task.description)
else:
memory_context = task.memory.build_context_for_task(task.description)
memory_context = task.memory.build_context_for_task(task.description)
if memory_context:
# Log detailed memory context for debugging
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
@@ -680,7 +652,7 @@ class AgentTeam(SpawnAnnounceProtocol):
_stream = _output_config.stream
else:
_verbose = 0
_stream = False
_stream = True
# ─────────────────────────────────────────────────────────────────────
# Resolve EXECUTION param using canonical resolver
@@ -1056,65 +1028,6 @@ class AgentTeam(SpawnAnnounceProtocol):
task_result = _process_task_result(self, context, agent_output)
return task_result.task_output
def _apply_task_guardrail(self, task, task_id, task_output):
"""Apply guardrail validation to task output.
Returns:
tuple: (task_output, should_retry) where should_retry is True if task should be retried
Raises:
Exception: If guardrail validation fails after max retries
"""
if not task._guardrail_fn:
return task_output, False
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}")
return task_output, True # Signal retry needed
# 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")
return task_output, False # No retry needed
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}")
return task_output, True # Signal retry needed
async def arun_task(self, task_id):
"""Async version of run_task method"""
if task_id not in self.tasks:
@@ -1133,11 +1046,53 @@ class AgentTeam(SpawnAnnounceProtocol):
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):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
if should_retry:
retries += 1
continue
# 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
@@ -1151,6 +1106,20 @@ class AgentTeam(SpawnAnnounceProtocol):
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):
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:
logger.error(f"Error executing task callback for task {task_id}: {e}")
logger.exception(e)
self.save_output_to_file(task, task_output)
if self.verbose >= 1:
@@ -1365,11 +1334,52 @@ class AgentTeam(SpawnAnnounceProtocol):
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):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
if should_retry:
retries += 1
continue
# 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
@@ -1379,6 +1389,20 @@ class AgentTeam(SpawnAnnounceProtocol):
except Exception as e:
logger.error(f"Error executing memory callback for task {task_id}: {e}")
logger.exception(e)
# Run task callback if exists
if task.callback:
try:
if asyncio.iscoroutinefunction(task.callback):
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:
logger.error(f"Error executing task callback for task {task_id}: {e}")
logger.exception(e)
self.save_output_to_file(task, task_output)
@@ -1969,8 +1993,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Define the endpoint handler
@app.post(path)
async def handle_query(request: Request, query_data: Optional[AgentQuery] = None):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
@@ -2049,9 +2071,7 @@ class AgentTeam(SpawnAnnounceProtocol):
# Add GET endpoint to list available agents
@app.get(f"{path}/list")
async def list_agents(request: Request):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
async def list_agents():
return {
"agents": [
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
@@ -2066,8 +2086,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Create a closure to capture the agent instance
def create_agent_handler(agent):
async def handle_single_agent(request: Request):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
try:
request_data = await request.json()
query = request_data.get("query", "")
@@ -53,4 +53,3 @@ class AgentAppConfig:
workers: int = 1
timeout: int = 60
metadata: Dict[str, Any] = field(default_factory=dict)
api_key: Optional[str] = None
@@ -74,15 +74,11 @@ class ApprovalConfig:
- *positive float* — wait up to that many seconds.
- ``None`` — wait **indefinitely** (no timeout).
- *unset / 0* — use the backend's own default timeout.
permissions: Declarative permission rules mapping patterns to actions
(e.g., {"read:*": "allow", "bash:rm *": "deny"}).
Used for CI-safe, non-interactive permission policies.
"""
backend: Any = None
all_tools: bool = False
timeout: Optional[float] = 0
permissions: Optional[Dict[str, Any]] = None
@runtime_checkable
@@ -194,13 +194,9 @@ class ApprovalRegistry:
def is_yaml_approved(self, tool_name: str) -> bool:
try:
if tool_name not in self._yaml_approved_tools.get():
return False
return tool_name in self._yaml_approved_tools.get()
except LookupError:
return False
if self.get_risk_level(tool_name) == "critical":
return False
return True
# ── Env-var check ────────────────────────────────────────────────────
@@ -8,7 +8,23 @@ All implementations live in the praisonai wrapper package.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
from .protocols import (
BotOSProtocol, # noqa: F401
BotProtocol,
BotMessageProtocol,
BotUserProtocol,
BotChannelProtocol,
BotMessage,
BotUser,
BotChannel,
MessageType,
ChatCommandInfo,
ChatCommandProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .protocols import (
BotProtocol,
BotMessageProtocol,
@@ -25,31 +41,6 @@ from .protocols import (
HealthResult,
EmailProtocol,
EmailInbox,
SupportsPresentation,
PlatformCapabilities,
)
from .presentation import (
MessagePresentation,
PresentationBlock,
PresentationButton,
PresentationAction,
SelectOption,
PresentationLimits,
ActionType,
ButtonStyle,
BlockType,
adapt_presentation,
)
from .interactive import (
InteractiveContext,
InteractiveRegistry,
InteractiveHandler,
encode_action,
decode_callback,
create_registry,
get_registry,
register_handler,
unregister_handler,
)
from .config import BotConfig, BotOSConfig
@@ -71,25 +62,4 @@ __all__ = [
"HealthResult",
"EmailProtocol",
"EmailInbox",
"MessagePresentation",
"PresentationBlock",
"PresentationButton",
"PresentationAction",
"SelectOption",
"PresentationLimits",
"SupportsPresentation",
"ActionType",
"ButtonStyle",
"BlockType",
"adapt_presentation",
"PlatformCapabilities",
"InteractiveContext",
"InteractiveRegistry",
"InteractiveHandler",
"encode_action",
"decode_callback",
"create_registry",
"get_registry",
"register_handler",
"unregister_handler",
]
@@ -91,16 +91,6 @@ class BotConfig:
# When set, stale sessions older than this are auto-reaped.
session_ttl: int = 0
# In-flight run control settings for mid-run message handling
# busy_mode: Policy for handling messages during active runs
# - "queue": Queue message for next turn (default)
# - "interrupt": Cancel current run and start new one
# - "steer": Inject message into current run via steering
busy_mode: str = "queue"
# Template for busy acknowledgment messages (use {action} placeholder)
busy_ack: str = "⏳ {action} — will be considered next"
# 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)
@@ -111,16 +101,6 @@ class BotConfig:
# Owner user ID for pairing approvals (platform-specific format)
owner_user_id: Optional[str] = None
# Progressive streaming for channel bots (default: False)
streaming: bool = False
# Edit interval for streaming responses in milliseconds (default: 700ms)
stream_edit_interval_ms: int = 700
# Intentional silence support
allow_silence: bool = False
silence_token: Optional[str] = None
def __post_init__(self) -> None:
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
@@ -156,17 +136,11 @@ class BotConfig:
"ack_emoji": self.ack_emoji,
"done_emoji": self.done_emoji,
"session_ttl": self.session_ttl,
"busy_mode": self.busy_mode,
"busy_ack": self.busy_ack,
"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,
"streaming": self.streaming,
"stream_edit_interval_ms": self.stream_edit_interval_ms,
"allow_silence": self.allow_silence,
"silence_token": self.silence_token,
"metadata": self.metadata,
}
@@ -1,271 +0,0 @@
"""
Interactive action dispatch system for messaging bots.
Provides a registry and dispatch protocol for handling callbacks from
interactive UI elements (buttons, select menus) across all messaging platforms.
This complements the presentation.py render side with symmetric inbound handling.
This is a core protocol with no heavy implementations - channel-specific
callback decoding belongs in the wrapper (praisonai).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .presentation import PresentationAction
from .protocols import BotAdapter, BotMessage
logger = logging.getLogger(__name__)
@dataclass
class InteractiveContext:
"""Context for interactive callback handling.
Attributes:
callback_data: Raw callback data from the platform
user_id: ID of the user who triggered the action
message_id: ID of the message containing the interactive element
chat_id: ID of the chat where the action was triggered
bot_adapter: The bot adapter handling this interaction
platform_data: Platform-specific additional data
"""
callback_data: str
user_id: str
message_id: Optional[str] = None
chat_id: Optional[str] = None
bot_adapter: Optional["BotAdapter"] = None
platform_data: Dict[str, Any] = field(default_factory=dict)
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]
def encode_action(namespace: str, action: "PresentationAction") -> str:
"""Encode an action with namespace for callback data.
Args:
namespace: The namespace for this action handler
action: The presentation action to encode
Returns:
Encoded callback data string
"""
from .presentation import ActionType
if action.type == ActionType.CALLBACK:
# For callback type, encode namespace with the value
if action.value:
return f"{namespace}:{action.value}"
return namespace
elif action.type == ActionType.COMMAND:
# For command type, use cmd: prefix
if action.command:
return f"cmd:{action.command}"
return namespace
else:
# URL and web_app types don't need encoding
return namespace
def decode_callback(data: str) -> Tuple[str, Dict[str, Any]]:
"""Decode callback data into namespace and payload.
Args:
data: Raw callback data string
Returns:
Tuple of (namespace, payload_dict)
"""
if not data:
return ("unknown", {})
# Handle command callbacks (cmd:)
if data.startswith("cmd:"):
command = data[4:]
return ("command", {"command": command})
# Handle namespaced callbacks (namespace:payload)
if ":" in data:
parts = data.split(":", 1)
namespace = parts[0]
payload = parts[1] if len(parts) > 1 else ""
return (namespace, {"value": payload})
# Plain data without namespace
return (data, {})
class InteractiveRegistry:
"""Registry for interactive callback handlers.
Manages registration and dispatch of handlers for different
callback namespaces. Each namespace can have one handler.
"""
def __init__(self):
"""Initialize the registry."""
self._handlers: Dict[str, InteractiveHandler] = {}
self._fallback_handler: Optional[InteractiveHandler] = None
def register(
self,
namespace: str,
handler: InteractiveHandler
) -> None:
"""Register a handler for a namespace.
Args:
namespace: The namespace to handle (e.g., "approval", "menu")
handler: Async function to handle callbacks in this namespace
"""
if namespace in self._handlers:
logger.warning(f"Overwriting existing handler for namespace: {namespace}")
self._handlers[namespace] = handler
logger.debug(f"Registered handler for namespace: {namespace}")
def unregister(self, namespace: str) -> None:
"""Unregister a handler.
Args:
namespace: The namespace to unregister
"""
if namespace in self._handlers:
del self._handlers[namespace]
logger.debug(f"Unregistered handler for namespace: {namespace}")
def set_fallback(self, handler: InteractiveHandler) -> None:
"""Set a fallback handler for unmatched callbacks.
Args:
handler: Async function to handle unmatched callbacks
"""
self._fallback_handler = handler
async def dispatch(self, context: InteractiveContext) -> bool:
"""Dispatch a callback to the appropriate handler.
Args:
context: The interactive context
Returns:
True if handled, False otherwise
"""
namespace, payload = decode_callback(context.callback_data)
# Try to find a handler for this namespace
handler = self._handlers.get(namespace)
if handler:
try:
# Add decoded payload to context
context.platform_data["decoded_namespace"] = namespace
context.platform_data["decoded_payload"] = payload
result = await handler(context)
if result:
logger.debug(f"Handler for namespace '{namespace}' returned: {result}")
return True
else:
logger.debug(f"Handler for namespace '{namespace}' returned None, trying fallback")
except Exception as e:
logger.error(f"Error in handler for namespace '{namespace}': {e}")
# Continue to fallback handler
# Try fallback handler
if self._fallback_handler:
try:
context.platform_data["decoded_namespace"] = namespace
context.platform_data["decoded_payload"] = payload
result = await self._fallback_handler(context)
if result:
logger.debug(f"Fallback handler returned: {result}")
return True
else:
logger.debug(f"Fallback handler returned None")
return False
except Exception as e:
logger.error(f"Error in fallback handler: {e}")
return False
logger.debug(f"No handler found for namespace: {namespace}")
return False
def has_handler(self, namespace: str) -> bool:
"""Check if a namespace has a registered handler.
Args:
namespace: The namespace to check
Returns:
True if handler exists
"""
return namespace in self._handlers
def list_namespaces(self) -> list[str]:
"""List all registered namespaces.
Returns:
List of namespace names
"""
return list(self._handlers.keys())
# Global registry instance - deprecated, use create_registry() for new code
_global_registry = InteractiveRegistry()
def create_registry() -> InteractiveRegistry:
"""Create a new interactive registry instance.
Each adapter should create its own registry to avoid conflicts
when multiple adapters are used in the same process.
Returns:
A new InteractiveRegistry instance
"""
return InteractiveRegistry()
def get_registry() -> InteractiveRegistry:
"""Get the global interactive registry.
.. deprecated::
Use create_registry() to create adapter-specific registries instead.
The global registry can cause conflicts when multiple adapters are used.
Returns:
The global InteractiveRegistry instance
"""
return _global_registry
def register_handler(namespace: str, handler: InteractiveHandler) -> None:
"""Register a handler in the global registry.
.. deprecated::
Use registry.register() on an adapter-specific registry instead.
Args:
namespace: The namespace to handle
handler: Async function to handle callbacks
"""
_global_registry.register(namespace, handler)
def unregister_handler(namespace: str) -> None:
"""Unregister a handler from the global registry.
.. deprecated::
Use registry.unregister() on an adapter-specific registry instead.
Args:
namespace: The namespace to unregister
"""
_global_registry.unregister(namespace)
@@ -1,651 +0,0 @@
"""
Presentation primitives for interactive UI in messaging bots.
Defines typed, portable presentation blocks (buttons, selects, text)
that channel adapters render as native widgets. Enables structured
interactive UI across Telegram, Slack, Discord, and other platforms.
This is a core protocol with no heavy implementations - channel-specific
rendering belongs in the wrapper (praisonai).
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
# Most channels (e.g. Telegram) hard-cap inline callback payloads at 64 bytes.
# Keep degraded select callbacks within this bound while preserving uniqueness.
_MAX_CALLBACK_LEN = 64
class ActionType(str, Enum):
"""Types of actions that can be triggered by interactive elements."""
COMMAND = "command" # Execute a slash command
CALLBACK = "callback" # Opaque callback data for the plugin
URL = "url" # Open a URL
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
class ButtonStyle(str, Enum):
"""Visual styles for buttons."""
PRIMARY = "primary" # Primary action (blue/green)
DANGER = "danger" # Destructive action (red)
SECONDARY = "secondary" # Secondary action (gray)
SUCCESS = "success" # Success action (green)
WARNING = "warning" # Warning action (yellow)
class BlockType(str, Enum):
"""Types of presentation blocks."""
TEXT = "text" # Text content
BUTTONS = "buttons" # Button row/grid
SELECT = "select" # Dropdown/select menu
DIVIDER = "divider" # Visual separator
CONTEXT = "context" # Contextual info (smaller text)
@dataclass
class PresentationAction:
"""An action triggered by an interactive element.
Attributes:
type: The action type (command, callback, url, web_app)
command: Slash command to execute (for type="command")
value: Callback data (for type="callback")
url: URL to open (for type="url")
web_app_url: Web app URL (for type="web_app")
"""
type: Union[ActionType, str]
command: Optional[str] = None
value: Optional[str] = None
url: Optional[str] = None
web_app_url: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"type": self.type.value if isinstance(self.type, ActionType) else self.type}
if self.command is not None:
data["command"] = self.command
if self.value is not None:
data["value"] = self.value
if self.url is not None:
data["url"] = self.url
if self.web_app_url is not None:
data["web_app_url"] = self.web_app_url
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationAction":
"""Create from dictionary."""
return cls(
type=data.get("type", "callback"),
command=data.get("command"),
value=data.get("value"),
url=data.get("url"),
web_app_url=data.get("web_app_url"),
)
@dataclass
class PresentationButton:
"""A button in an interactive presentation.
Attributes:
label: Button text label
action: Action to trigger when clicked
url: Direct URL (alternative to action)
priority: Truncation priority (higher survives)
style: Visual style
disabled: Whether button is disabled
"""
label: str
action: Optional[PresentationAction] = None
url: Optional[str] = None
priority: int = 0
style: Optional[Union[ButtonStyle, str]] = None
disabled: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"label": self.label, "priority": self.priority, "disabled": self.disabled}
if self.action is not None:
data["action"] = self.action.to_dict()
if self.url is not None:
data["url"] = self.url
if self.style is not None:
data["style"] = self.style.value if isinstance(self.style, ButtonStyle) else self.style
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationButton":
"""Create from dictionary."""
return cls(
label=data.get("label", ""),
action=PresentationAction.from_dict(data["action"]) if "action" in data else None,
url=data.get("url"),
priority=data.get("priority", 0),
style=data.get("style"),
disabled=data.get("disabled", False),
)
@dataclass
class SelectOption:
"""An option in a select menu.
Attributes:
label: Option text label
value: Option value
description: Optional description
emoji: Optional emoji icon
default: Whether this is the default option
"""
label: str
value: str
description: Optional[str] = None
emoji: Optional[str] = None
default: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"label": self.label, "value": self.value, "default": self.default}
if self.description is not None:
data["description"] = self.description
if self.emoji is not None:
data["emoji"] = self.emoji
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SelectOption":
"""Create from dictionary."""
return cls(
label=data.get("label", ""),
value=data.get("value", ""),
description=data.get("description"),
emoji=data.get("emoji"),
default=data.get("default", False),
)
@dataclass
class PresentationBlock:
"""A block in a message presentation.
Blocks are the building blocks of interactive messages.
Each block type has specific properties and behavior.
"""
type: Union[BlockType, str]
text: Optional[str] = None
buttons: Optional[List[PresentationButton]] = None
options: Optional[List[SelectOption]] = None
placeholder: Optional[str] = None
action_id: Optional[str] = None
@staticmethod
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
"""Create a text block."""
return PresentationBlock(type=BlockType.TEXT, text=content)
@staticmethod
def make_buttons(items: List[PresentationButton]) -> "PresentationBlock":
"""Create a buttons block."""
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
@staticmethod
def make_select(
options: List[SelectOption],
placeholder: Optional[str] = None,
action_id: Optional[str] = None,
) -> "PresentationBlock":
"""Create a select menu block."""
return PresentationBlock(
type=BlockType.SELECT,
options=options,
placeholder=placeholder,
action_id=action_id,
)
@staticmethod
def make_divider() -> "PresentationBlock":
"""Create a divider block."""
return PresentationBlock(type=BlockType.DIVIDER)
@staticmethod
def make_context(content: str) -> "PresentationBlock":
"""Create a context block (smaller text)."""
return PresentationBlock(type=BlockType.CONTEXT, text=content)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"type": self.type.value if isinstance(self.type, BlockType) else self.type}
if self.text is not None:
data["text"] = self.text
if self.buttons is not None:
data["buttons"] = [b.to_dict() for b in self.buttons]
if self.options is not None:
data["options"] = [o.to_dict() for o in self.options]
if self.placeholder is not None:
data["placeholder"] = self.placeholder
if self.action_id is not None:
data["action_id"] = self.action_id
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationBlock":
"""Create from dictionary."""
return cls(
type=data.get("type", "text"),
text=data.get("text"),
buttons=(
[PresentationButton.from_dict(b) for b in data["buttons"]]
if "buttons" in data else None
),
options=(
[SelectOption.from_dict(o) for o in data["options"]]
if "options" in data else None
),
placeholder=data.get("placeholder"),
action_id=data.get("action_id"),
)
@dataclass
class MessagePresentation:
"""A complete interactive message presentation.
Attributes:
blocks: List of presentation blocks
tone: Optional tone/style hint for the whole message
ephemeral: Whether message should be ephemeral/temporary
replace_message_id: ID of message to replace (for updates)
"""
blocks: List[PresentationBlock] = field(default_factory=list)
tone: Optional[str] = None
ephemeral: bool = False
replace_message_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"blocks": [b.to_dict() for b in self.blocks], "ephemeral": self.ephemeral}
if self.tone is not None:
data["tone"] = self.tone
if self.replace_message_id is not None:
data["replace_message_id"] = self.replace_message_id
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MessagePresentation":
"""Create from dictionary."""
return cls(
blocks=[PresentationBlock.from_dict(b) for b in data.get("blocks", [])],
tone=data.get("tone"),
ephemeral=data.get("ephemeral", False),
replace_message_id=data.get("replace_message_id"),
)
@staticmethod
def approval(
prompt: str,
approval_id: str,
allow_always: bool = False,
context: Optional[str] = None,
) -> "MessagePresentation":
"""Create a standard approval presentation.
Args:
prompt: The approval prompt text
approval_id: Unique ID for this approval
allow_always: Whether to include "Allow Always" option
context: Optional context information
Returns:
A presentation with standard approval buttons
"""
blocks = []
# Add prompt
blocks.append(PresentationBlock.make_text(prompt))
# Add context if provided
if context:
blocks.append(PresentationBlock.make_context(context))
# Create buttons
buttons = [
PresentationButton(
label="Allow Once",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} allow-once"
),
style=ButtonStyle.PRIMARY,
priority=10,
),
PresentationButton(
label="Deny",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} deny"
),
style=ButtonStyle.DANGER,
priority=9,
),
]
if allow_always:
buttons.insert(1, PresentationButton(
label="Allow Always",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} allow-always"
),
style=ButtonStyle.SUCCESS,
priority=8,
))
blocks.append(PresentationBlock.make_buttons(buttons))
return MessagePresentation(blocks=blocks)
@dataclass
class PresentationLimits:
"""Channel-specific limits for presentations.
Attributes:
max_buttons: Maximum buttons per row/message
max_button_rows: Maximum button rows
max_button_label: Maximum characters in button label
max_options: Maximum options in select menu
max_option_label: Maximum characters in option label
max_text_length: Maximum text block length
supports_markdown: Whether channel supports markdown
supports_select: Whether channel supports select menus
supports_web_apps: Whether channel supports web apps
"""
max_buttons: int = 10
max_button_rows: int = 5
max_button_label: int = 50
max_options: int = 25
max_option_label: int = 50
max_text_length: int = 4096
supports_markdown: bool = True
supports_select: bool = True
supports_web_apps: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"max_buttons": self.max_buttons,
"max_button_rows": self.max_button_rows,
"max_button_label": self.max_button_label,
"max_options": self.max_options,
"max_option_label": self.max_option_label,
"max_text_length": self.max_text_length,
"supports_markdown": self.supports_markdown,
"supports_select": self.supports_select,
"supports_web_apps": self.supports_web_apps,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationLimits":
"""Create from dictionary."""
return cls(
max_buttons=data.get("max_buttons", 10),
max_button_rows=data.get("max_button_rows", 5),
max_button_label=data.get("max_button_label", 50),
max_options=data.get("max_options", 25),
max_option_label=data.get("max_option_label", 50),
max_text_length=data.get("max_text_length", 4096),
supports_markdown=data.get("supports_markdown", True),
supports_select=data.get("supports_select", True),
supports_web_apps=data.get("supports_web_apps", False),
)
@staticmethod
def telegram() -> "PresentationLimits":
"""Get Telegram-specific limits."""
return PresentationLimits(
max_buttons=8,
max_button_rows=100,
max_button_label=64,
max_options=0, # No native select
max_text_length=4096,
supports_markdown=True,
supports_select=False,
supports_web_apps=True,
)
@staticmethod
def slack() -> "PresentationLimits":
"""Get Slack-specific limits."""
return PresentationLimits(
max_buttons=5,
max_button_rows=1,
max_button_label=75,
max_options=100,
max_option_label=75,
max_text_length=3000,
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
)
@staticmethod
def discord() -> "PresentationLimits":
"""Get Discord-specific limits."""
return PresentationLimits(
max_buttons=5, # per-row capacity; total cap = max_buttons * max_button_rows = 25
max_button_rows=5,
max_button_label=80,
max_options=25,
max_option_label=100,
max_text_length=2000,
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
)
def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> PresentationButton:
"""Return a copy of a button adapted to the given limits.
Truncates the label and, when the channel does not support web apps,
degrades a ``web_app`` action to a plain URL so the button still works.
"""
label = button.label or ""
if limits.max_button_label and len(label) > limits.max_button_label:
label = label[: limits.max_button_label]
action = button.action
url = button.url
if action is not None and not limits.supports_web_apps:
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
if action_type == ActionType.WEB_APP.value and action.web_app_url:
# Degrade web_app -> url when unsupported
if url is None:
url = action.web_app_url
action = PresentationAction(type=ActionType.URL, url=action.web_app_url)
return PresentationButton(
label=label,
action=action,
url=url,
priority=button.priority,
style=button.style,
disabled=button.disabled,
)
def _encode_select_callback(action_id: str, value: str) -> str:
"""Build a channel-safe callback payload for a degraded select option.
The raw ``select:<action_id>:<value>`` form can exceed channel callback
limits (e.g. Telegram's 64-byte cap) and collide when distinct options
share a long prefix. When the raw payload fits within ``_MAX_CALLBACK_LEN``
it is returned unchanged; otherwise the value is replaced with a short,
collision-resistant hash so distinct options stay distinct after truncation.
"""
raw = f"select:{action_id}:{value}"
if len(raw) <= _MAX_CALLBACK_LEN:
return raw
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
prefix = f"select:{action_id}:"
# Reserve room for the digest; trim the action_id prefix if needed.
budget = _MAX_CALLBACK_LEN - len(digest)
if budget < len("select::"):
# action_id itself is too long; hash it too to guarantee the bound.
aid_digest = hashlib.sha1((action_id or "").encode("utf-8")).hexdigest()[:8]
prefix = f"select:{aid_digest}:"
return f"{prefix[:_MAX_CALLBACK_LEN - len(digest)]}{digest}"
def _select_to_buttons(block: PresentationBlock) -> PresentationBlock:
"""Convert a SELECT block into an equivalent BUTTONS block.
Used when a channel does not support native select menus. Each option
becomes a callback button carrying a bounded, channel-safe identifier
derived from ``select:<action_id>:<value>``.
"""
buttons: List[PresentationButton] = []
action_id = block.action_id or ""
for option in (block.options or []):
label = option.label
if option.emoji:
label = f"{option.emoji} {label}"
buttons.append(
PresentationButton(
label=label,
action=PresentationAction(
type=ActionType.CALLBACK,
value=_encode_select_callback(action_id, option.value),
),
)
)
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
def adapt_presentation(
presentation: MessagePresentation,
limits: PresentationLimits,
) -> MessagePresentation:
"""Return a copy of ``presentation`` guaranteed to satisfy ``limits``.
This is the single, channel-agnostic adaptation pass that channel
renderers should run before mapping a presentation to native widgets.
It performs:
1. Priority-aware button truncation: when a buttons block exceeds
``max_buttons`` (or the implied ``max_buttons * max_button_rows``
cap), the lowest-``priority`` buttons are dropped first (highest
survives), preserving original order among kept buttons.
2. Label truncation to ``max_button_label`` / ``max_option_label``.
3. Option truncation to ``max_options``.
4. Capability degradation: ``select`` blocks become button rows when
``supports_select`` is False; ``web_app`` actions become URLs when
``supports_web_apps`` is False.
The input presentation is never mutated.
Args:
presentation: The portable presentation to adapt.
limits: The target channel's capability limits.
Returns:
A new ``MessagePresentation`` that is safe to render natively.
"""
adapted_blocks: List[PresentationBlock] = []
for block in presentation.blocks:
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Degrade select -> buttons, then adapt the resulting buttons block
block = _select_to_buttons(block)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.BUTTONS.value and block.buttons:
# Cap total buttons by max_buttons * max_button_rows (interpreting
# max_buttons as per-row capacity), falling back to max_buttons.
rows = limits.max_button_rows if limits.max_button_rows else 1
total_cap = limits.max_buttons * rows if limits.max_buttons else len(block.buttons)
if total_cap <= 0:
total_cap = len(block.buttons)
buttons = list(block.buttons)
if len(buttons) > total_cap:
# Keep highest-priority buttons; preserve original order among kept.
indexed = list(enumerate(buttons))
kept = sorted(
indexed,
key=lambda iv: (iv[1].priority, -iv[0]),
reverse=True,
)[:total_cap]
kept.sort(key=lambda iv: iv[0])
buttons = [b for _, b in kept]
adapted_buttons = [_adapt_button(b, limits) for b in buttons]
adapted_blocks.append(
PresentationBlock(type=BlockType.BUTTONS, buttons=adapted_buttons)
)
continue
if block_type == BlockType.SELECT.value and block.options:
options = block.options
if limits.max_options and len(options) > limits.max_options:
options = options[: limits.max_options]
new_options: List[SelectOption] = []
for option in options:
label = option.label
if limits.max_option_label and len(label) > limits.max_option_label:
label = label[: limits.max_option_label]
new_options.append(
SelectOption(
label=label,
value=option.value,
description=option.description,
emoji=option.emoji,
default=option.default,
)
)
adapted_blocks.append(
PresentationBlock(
type=BlockType.SELECT,
options=new_options,
placeholder=block.placeholder,
action_id=block.action_id,
)
)
continue
if block_type == BlockType.TEXT.value or block_type == BlockType.CONTEXT.value:
text = block.text
if text is not None and limits.max_text_length and len(text) > limits.max_text_length:
text = text[: limits.max_text_length]
adapted_blocks.append(
PresentationBlock(type=block.type, text=text)
)
continue
adapted_blocks.append(block)
return MessagePresentation(
blocks=adapted_blocks,
tone=presentation.tone,
ephemeral=presentation.ephemeral,
replace_message_id=presentation.replace_message_id,
)
@@ -22,7 +22,6 @@ from typing import (
List,
Optional,
Protocol,
TypedDict,
Union,
runtime_checkable,
)
@@ -31,105 +30,6 @@ if TYPE_CHECKING:
from ..agent import Agent
class ChannelCapabilities(TypedDict, total=False):
"""Declares what features a bot channel supports.
This allows shared engines to adapt behavior based on platform capabilities,
enabling graceful degradation when features aren't available.
Attributes:
live_edit: Whether the channel supports editing messages in place
reactions: Whether the channel supports adding reactions to messages
typing: Whether the channel supports typing indicators
text_limit: Maximum message length (0 = unlimited)
edit_rate_limit: Minimum seconds between edits (for throttling)
reaction_rate_limit: Minimum seconds between reactions
"""
live_edit: bool
reactions: bool
typing: bool
text_limit: int
edit_rate_limit: float
reaction_rate_limit: float
class RunStatus(Enum):
"""Run status states for progress feedback.
Used by status engines to show agent execution state through
reactions or status lines.
"""
QUEUED = "queued"
THINKING = "thinking"
TOOL = "tool"
DONE = "done"
ERROR = "error"
@dataclass(frozen=True)
class PlatformCapabilities:
"""Platform-specific capabilities descriptor for bot adapters.
Declares what a messaging platform can do, enabling the shared delivery
machinery to apply features like streaming, rate limiting, and chunking
uniformly across all platforms.
Attributes:
max_message_length: Maximum message length in the platform's unit
length_unit: Unit for message length ("codepoints" or "utf16")
supports_edit: Whether the platform supports in-place message edits (for streaming)
supports_typing: Whether the platform supports typing indicators
markdown_dialect: Markdown flavor the platform uses (e.g., "markdown", "telegram_markdown_v2")
needs_rate_limit: Whether the platform needs rate limiting
edit_interval_ms: Minimum milliseconds between message edits (for streaming)
max_files_per_message: Maximum number of file attachments per message
max_file_size_mb: Maximum file size in megabytes
supported_file_types: List of supported file extensions/mime types
"""
max_message_length: int = 4096
length_unit: str = "codepoints" # "codepoints" or "utf16"
supports_edit: bool = False
supports_typing: bool = True
markdown_dialect: str = "markdown"
needs_rate_limit: bool = True
edit_interval_ms: int = 1000 # Minimum ms between edits
max_files_per_message: int = 1
max_file_size_mb: int = 10
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"max_message_length": self.max_message_length,
"length_unit": self.length_unit,
"supports_edit": self.supports_edit,
"supports_typing": self.supports_typing,
"markdown_dialect": self.markdown_dialect,
"needs_rate_limit": self.needs_rate_limit,
"edit_interval_ms": self.edit_interval_ms,
"max_files_per_message": self.max_files_per_message,
"max_file_size_mb": self.max_file_size_mb,
"supported_file_types": self.supported_file_types,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PlatformCapabilities":
"""Create from dictionary."""
return cls(
max_message_length=data.get("max_message_length", 4096),
length_unit=data.get("length_unit", "codepoints"),
supports_edit=data.get("supports_edit", False),
supports_typing=data.get("supports_typing", True),
markdown_dialect=data.get("markdown_dialect", "markdown"),
needs_rate_limit=data.get("needs_rate_limit", True),
edit_interval_ms=data.get("edit_interval_ms", 1000),
max_files_per_message=data.get("max_files_per_message", 1),
max_file_size_mb=data.get("max_file_size_mb", 10),
supported_file_types=data.get("supported_file_types", ["*"]),
)
class MessageType(str, Enum):
"""Types of bot messages."""
@@ -418,80 +318,6 @@ class ProbeResult:
}
class HealthReason(Enum):
"""Reasons for channel health status."""
HEALTHY = "healthy"
NOT_RUNNING = "not-running"
DISCONNECTED = "disconnected"
STALE_SOCKET = "stale-socket"
STUCK = "stuck"
BUSY = "busy"
STARTUP_GRACE = "startup-grace"
ERROR = "error"
@property
def is_recoverable(self) -> bool:
"""Whether this reason indicates a recoverable state."""
return self in {
HealthReason.DISCONNECTED,
HealthReason.STALE_SOCKET,
HealthReason.STUCK,
HealthReason.ERROR,
}
def evaluate_channel_health(
health: HealthResult,
startup_grace_seconds: float = 60.0,
stale_after_seconds: float = 120.0,
current_time: Optional[float] = None,
) -> HealthReason:
"""Evaluate channel health and return a reason.
Pure function that evaluates a HealthResult and determines
the health reason based on various criteria.
Args:
health: The health result to evaluate
startup_grace_seconds: Grace period for startup
stale_after_seconds: Time after which no activity is considered stale
current_time: Current timestamp (for testing)
Returns:
HealthReason indicating the channel's health status
"""
if current_time is None:
current_time = time.time()
# Not running
if not health.is_running:
return HealthReason.NOT_RUNNING
# Startup grace period
if health.uptime_seconds is not None and health.uptime_seconds < startup_grace_seconds:
return HealthReason.STARTUP_GRACE
# Check for errors
if health.error:
return HealthReason.ERROR
# Check probe result
if health.probe and not health.probe.ok:
return HealthReason.DISCONNECTED
# Check for stale socket (no transport activity)
if health.last_activity is not None:
time_since_activity = current_time - health.last_activity
if time_since_activity > stale_after_seconds:
return HealthReason.STALE_SOCKET
# Overall health status
if not health.ok:
return HealthReason.ERROR
return HealthReason.HEALTHY
@dataclass
class HealthResult:
"""Detailed health status of a bot.
@@ -505,8 +331,6 @@ class HealthResult:
sessions: Number of active sessions
error: Error message (if unhealthy)
details: Additional platform-specific health details
reason: Health status reason (optional)
last_activity: Last transport activity timestamp (optional)
"""
ok: bool
@@ -517,8 +341,6 @@ class HealthResult:
sessions: int = 0
error: Optional[str] = None
details: Dict[str, Any] = field(default_factory=dict)
reason: Optional[HealthReason] = None
last_activity: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -531,8 +353,6 @@ class HealthResult:
"sessions": self.sessions,
"error": self.error,
"details": self.details,
"reason": self.reason.value if self.reason else None,
"last_activity": self.last_activity,
}
@@ -568,24 +388,6 @@ class BotProtocol(Protocol):
"""The bot's user information."""
...
@property
def capabilities(self) -> ChannelCapabilities:
"""Channel capabilities for feature discovery.
Returns capabilities that shared engines use to adapt behavior.
Channels that don't support a feature should return False for it.
"""
...
@property
def platform_capabilities(self) -> PlatformCapabilities:
"""Platform capabilities descriptor.
Returns the platform's capabilities for use by shared delivery code.
Adapters should override this to declare their specific capabilities.
"""
...
# Lifecycle methods
async def start(self) -> None:
"""Start the bot (begin receiving messages)."""
@@ -697,33 +499,6 @@ class BotProtocol(Protocol):
"""
...
# Reactions
async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
"""Add a reaction emoji to a message.
Args:
channel_id: Channel containing the message
message_id: Message to react to
emoji: Emoji to add (Unicode or custom emoji ID)
Returns:
True if reaction was added successfully
"""
...
async def remove_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
"""Remove a reaction emoji from a message.
Args:
channel_id: Channel containing the message
message_id: Message to remove reaction from
emoji: Emoji to remove
Returns:
True if reaction was removed successfully
"""
...
# User/channel info
async def get_user(self, user_id: str) -> Optional[BotUser]:
"""Get user information.
@@ -838,55 +613,6 @@ class ChatCommandProtocol(Protocol):
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SupportsPresentation — interactive UI presentation protocol
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@runtime_checkable
class SupportsPresentation(Protocol):
"""Protocol for channel adapters that support presentations.
Channel adapters implement this protocol to render
portable presentations as native widgets.
"""
@property
def presentation_limits(self) -> "PresentationLimits":
"""Get channel-specific presentation limits."""
...
async def render_presentation(
self,
target: str,
presentation: "MessagePresentation",
) -> Optional[str]:
"""Render a presentation to a target (chat/channel).
Args:
target: Target identifier (chat_id, channel_id, etc.)
presentation: The presentation to render
Returns:
Message ID if sent successfully, None otherwise
"""
...
def truncate_presentation(
self,
presentation: "MessagePresentation",
) -> "MessagePresentation":
"""Truncate a presentation to fit channel limits.
Args:
presentation: The presentation to truncate
Returns:
Truncated presentation that fits channel limits
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -1,52 +0,0 @@
"""
Silence protocol for bot gateways.
Provides a canonical contract for agents to signal intentional silence
(no reply) in group/ambient channels. Zero dependencies.
"""
SILENT_REPLY_TOKEN = "NO_REPLY"
_MARKERS = frozenset({"NO_REPLY", "[SILENT]", "SILENT"})
def is_intentional_silence_response(text: str | None) -> bool:
"""Check if a response is an intentional silence signal.
True only when the reply is *exactly* a silence marker (not prose mentioning it).
Blank/empty responses are NOT treated as deliberate silence.
Args:
text: The response text to check
Returns:
True if the response is exactly a silence token, False otherwise
Examples:
>>> is_intentional_silence_response("NO_REPLY")
True
>>> is_intentional_silence_response("[SILENT]")
True
>>> is_intentional_silence_response("I think NO_REPLY is good")
False
>>> is_intentional_silence_response("")
False
>>> is_intentional_silence_response(None)
False
"""
if not text:
return False # blank != deliberate silence
normalized = text.strip().upper()
# Check for exact match
if normalized in _MARKERS:
return True
# Check for bracket-wrapped version (only for tokens that aren't already bracketed)
if normalized.startswith("[") and normalized.endswith("]"):
inner = normalized[1:-1]
if inner == "SILENT": # Only [SILENT] is a valid bracketed marker
return True
return False
@@ -170,17 +170,6 @@ class EventBus:
Returns:
The published Event object
"""
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
@@ -205,10 +194,6 @@ class EventBus:
"""
logger.debug(f"Publishing event: {event.type}")
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
# Store in history
with self._lock:
self._event_history.append(event)
@@ -260,15 +245,6 @@ class EventBus:
"""
type_str = event_type.value if isinstance(event_type, EventType) else event_type
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
event = Event(
type=type_str,
data=data or {},
@@ -345,11 +321,6 @@ class EventBus:
with self._lock:
return len(self._subscribers)
@property
def has_subscribers(self) -> bool:
"""Check if there are any subscribers (lock-free for performance)."""
return bool(self._subscribers)
def __repr__(self) -> str:
return f"EventBus(subscribers={self.subscriber_count})"
@@ -92,25 +92,9 @@ class CliBackendProtocol(Protocol):
Any object implementing these methods can serve as an Agent backend.
Follows typing.Protocol pattern from AGENTS.md.
Note: Starting with runtime capability system, all CLI backends must
declare their capabilities via the capabilities() method for compatibility
validation at config/selection time.
"""
config: CliBackendConfig
def capabilities(self) -> "RuntimeCapabilityMatrix": # type: ignore
"""Report capabilities supported by this CLI backend.
Required for integration with the runtime capability validation system.
CLI backends should honestly declare their capabilities to enable
fail-fast validation when agents require specific features.
Returns:
RuntimeCapabilityMatrix with supported capabilities
"""
...
async def execute(
self,
prompt: str,
@@ -33,10 +33,6 @@ __all__ = [
"CompactionStrategy",
# Results
"CompactionResult",
# Protocols
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
]
@@ -58,16 +54,4 @@ def __getattr__(name: str):
from .result import CompactionResult
return CompactionResult
if name == "ToolResultPrunerProtocol":
from .protocols import ToolResultPrunerProtocol
return ToolResultPrunerProtocol
if name == "MessageFormatterProtocol":
from .protocols import MessageFormatterProtocol
return MessageFormatterProtocol
if name == "SummaryBuilderProtocol":
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -5,13 +5,12 @@ Manages context window by compacting messages when needed.
"""
import re
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
from typing import List, Dict, Any, Optional, Callable, Awaitable
import asyncio
from .config import CompactionConfig, COMPACTION_PREFIX, SUMMARY_TEMPLATE
from .strategy import CompactionStrategy
from .result import CompactionResult
from .protocols import ToolResultPrunerProtocol, MessageFormatterProtocol, SummaryBuilderProtocol
class ContextCompactor:
@@ -35,10 +34,7 @@ class ContextCompactor:
preserve_system: bool = True,
preserve_recent: int = 5,
config: Optional[CompactionConfig] = None,
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None,
tool_pruner: Optional[ToolResultPrunerProtocol] = None,
message_formatter: Optional[MessageFormatterProtocol] = None,
summary_builder: Optional[SummaryBuilderProtocol] = None
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None
):
"""
Initialize the compactor.
@@ -49,43 +45,30 @@ class ContextCompactor:
strategy: Compaction strategy to use
preserve_system: Keep system messages
preserve_recent: Number of recent messages to preserve
config: Optional CompactionConfig for advanced settings
<<<<<<< HEAD
config: Optional CompactionConfig to override defaults
llm_summarize_fn: Async function to call LLM for summarization
tool_pruner: Protocol implementation for tool result pruning
message_formatter: Protocol implementation for message formatting
summary_builder: Protocol implementation for summary building
"""
# Use provided config or create default
self.config = config or CompactionConfig(
max_tokens=max_tokens,
target_tokens=target_tokens or int(max_tokens * 0.75),
preserve_system=preserve_system,
preserve_recent=preserve_recent
)
# Initialize config first
self.config = config or CompactionConfig()
# Set instance attributes from config to ensure consistency
self.max_tokens = self.config.max_tokens
self.target_tokens = self.config.target_tokens
# Use config values if config provided, otherwise use constructor args
if config is not None:
self.max_tokens = config.max_tokens
self.target_tokens = config.target_tokens
self.preserve_system = config.preserve_system
self.preserve_recent = config.preserve_recent
else:
self.max_tokens = max_tokens
self.target_tokens = target_tokens or int(max_tokens * 0.75)
self.preserve_system = preserve_system
self.preserve_recent = preserve_recent
self.strategy = strategy
self.preserve_system = self.config.preserve_system
self.preserve_recent = self.config.preserve_recent
self.llm_summarize_fn = llm_summarize_fn
# Protocol implementations (defaults to None - no heavy implementations in core)
self.tool_pruner = tool_pruner
self.message_formatter = message_formatter
self.summary_builder = summary_builder
# Anti-thrashing state tracking
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
self._low_savings_streak: int = 0
# Iterative summary state
# Track previous summaries for iterative update
self._previous_summary: Optional[str] = None
self._previous_summary_global_idx: int = 0
# Reset state tracking for each compact() call
self._used_previous_summary: bool = False
def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text."""
@@ -113,26 +96,12 @@ class ContextCompactor:
return sum(self.count_message_tokens(m) for m in messages)
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
"""
Check if messages need compaction with anti-thrashing protection.
Returns False if:
- Token count is below threshold
- We've had too many consecutive low-savings attempts
"""
if self.count_total_tokens(messages) <= self.max_tokens:
return False
# Anti-thrashing: skip if we've had too many low-savings attempts
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
return False
return True
"""Check if messages need compaction."""
return self.count_total_tokens(messages) > self.max_tokens
def compact(
self,
messages: List[Dict[str, Any]],
focus_topic: str = ""
messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], CompactionResult]:
"""
Compact messages to fit within token limit (synchronous version).
@@ -142,49 +111,31 @@ class ContextCompactor:
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Returns:
Tuple of (compacted messages, result)
"""
# Reset state for this compaction call
self._used_previous_summary = False
original_tokens = self.count_total_tokens(messages)
if original_tokens <= self.max_tokens:
result = CompactionResult(
return messages, CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
messages_removed=0,
messages_kept=len(messages),
strategy_used=self.strategy
)
result.calculate_savings_pct()
return messages, result
# Apply tool result deduplication pre-pass if enabled
processed_messages = messages
tool_results_pruned = 0
if self.config.tool_prune_before_summarise and self.tool_pruner:
processed_messages, tool_results_pruned = self.tool_pruner.prune(
messages,
self.config.max_tool_result_size
)
# Skip early exit - proceed with full compaction strategy
# Anti-thrashing check will be applied after strategy runs
if self.strategy == CompactionStrategy.TRUNCATE:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
elif self.strategy == CompactionStrategy.SLIDING:
compacted = self._sliding_window(processed_messages)
compacted = self._sliding_window(messages)
elif self.strategy == CompactionStrategy.SUMMARIZE:
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
elif self.strategy == CompactionStrategy.SMART:
compacted = self._smart_compact(processed_messages)
compacted = self._smart_compact(messages)
elif self.strategy == CompactionStrategy.PRUNE:
compacted = self._prune(processed_messages)
compacted = self._prune(messages)
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
if self.llm_summarize_fn:
# For sync calls with LLM function, we need to run async
@@ -193,17 +144,17 @@ class ContextCompactor:
try:
loop = asyncio.get_running_loop()
# If in async context, fallback to naive summarization
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
except RuntimeError:
# No running loop, safe to create one
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
compacted = asyncio.run(self._llm_summarize_async(messages))
except Exception:
# Fallback to naive summarization if async fails
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
else:
compacted = self._llm_summarize(processed_messages, focus_topic)
compacted = self._llm_summarize(messages)
else:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
compacted_tokens = self.count_total_tokens(compacted)
@@ -212,75 +163,49 @@ class ContextCompactor:
compacted_tokens=compacted_tokens,
messages_removed=len(messages) - len(compacted),
messages_kept=len(compacted),
strategy_used=self.strategy,
tool_results_pruned=tool_results_pruned,
previous_summary_reused=getattr(self, '_used_previous_summary', False)
strategy_used=self.strategy
)
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
async def compact_async(
self,
messages: List[Dict[str, Any]],
focus_topic: str = ""
messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], CompactionResult]:
"""
Compact messages to fit within token limit (asynchronous version).
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Returns:
Tuple of (compacted messages, result)
"""
# Reset state for this compaction call
self._used_previous_summary = False
original_tokens = self.count_total_tokens(messages)
if original_tokens <= self.max_tokens:
result = CompactionResult(
return messages, CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
messages_removed=0,
messages_kept=len(messages),
strategy_used=self.strategy
)
result.calculate_savings_pct()
return messages, result
# Apply tool result deduplication pre-pass if enabled
processed_messages = messages
tool_results_pruned = 0
if self.config.tool_prune_before_summarise and self.tool_pruner:
processed_messages, tool_results_pruned = self.tool_pruner.prune(
messages,
self.config.max_tool_result_size
)
if self.strategy == CompactionStrategy.TRUNCATE:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
elif self.strategy == CompactionStrategy.SLIDING:
compacted = self._sliding_window(processed_messages)
compacted = self._sliding_window(messages)
elif self.strategy == CompactionStrategy.SUMMARIZE:
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
elif self.strategy == CompactionStrategy.SMART:
compacted = self._smart_compact(processed_messages)
compacted = self._smart_compact(messages)
elif self.strategy == CompactionStrategy.PRUNE:
compacted = self._prune(processed_messages)
compacted = self._prune(messages)
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
compacted = await self._llm_summarize_async(messages)
else:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
compacted_tokens = self.count_total_tokens(compacted)
@@ -289,35 +214,11 @@ class ContextCompactor:
compacted_tokens=compacted_tokens,
messages_removed=len(messages) - len(compacted),
messages_kept=len(compacted),
strategy_used=self.strategy,
tool_results_pruned=tool_results_pruned,
previous_summary_reused=getattr(self, '_used_previous_summary', False)
strategy_used=self.strategy
)
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
def _prune_tool_results(self, messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
"""
Delegate tool result pruning to injected protocol implementation.
If no tool pruner is provided, returns messages unchanged.
This maintains backward compatibility while enforcing protocol-driven design.
Returns:
Tuple of (processed messages, number of tool results pruned)
"""
if self.tool_pruner:
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
return messages, 0
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Truncate oldest messages."""
result = []
@@ -343,17 +244,11 @@ class ContextCompactor:
# If still over limit, truncate more
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
# Remove oldest message (respecting preserve_system setting)
removed = False
# Remove oldest non-system message
for i, msg in enumerate(result):
# Skip system messages only if preserve_system is True
if not (self.preserve_system and msg.get("role") == "system"):
if msg.get("role") != "system":
result.pop(i)
removed = True
break
if not removed:
# Only system messages remain but still over budget — stop to avoid infinite loop
break
return result
@@ -465,117 +360,72 @@ class ContextCompactor:
result.extend(recent)
return result
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Use LLM to summarize older messages with iterative support.
Use LLM to summarize older messages with anti-injection framing.
Supports iterative summarization - if we have a previous summary,
we only summarize the new messages since that summary.
Also includes anti-injection framing when configured.
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Note: This implementation uses structured templates and anti-injection prefixes
to prevent the model from treating summarized content as active instructions.
"""
self._used_previous_summary = False
result = []
# Keep system messages
system_msgs = [m for m in messages if m.get("role") == "system"]
# Keep system messages (excluding previous compacted summaries)
system_msgs = [
m for m in messages
if m.get("role") == "system" and not m.get("_compacted")
]
other_msgs = [m for m in messages if m.get("role") != "system"]
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Determine what to summarize
total_original_messages = len(messages)
if (self.config.enable_iterative_summary and
self._previous_summary and
total_original_messages > self._previous_summary_global_idx):
# Iterative: summarize only new messages since previous summary
# Calculate how many old messages to summarize based on global position
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
if older:
if self.config.structured_template:
structured = self._build_structured_summary(older)
else:
to_summarize = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
self._used_previous_summary = False
if to_summarize or self._previous_summary:
# Build summary content
summary_parts = []
# Include previous summary if doing iterative summarization
if self._used_previous_summary and self._previous_summary:
summary_parts.append(f"[Previous Summary]: {self._previous_summary}")
summary_parts.append("[New Activity]:")
# Add new messages to summarize
for msg in to_summarize:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content:
# Extract key information with focus consideration
if focus_topic and focus_topic.lower() in content.lower():
# Prioritize content related to focus topic
summary_parts.append(f"[{role}] *FOCUS*: {content[:200]}...")
else:
# Fallback to simple summary
summary_parts = []
for msg in older:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content:
summary_parts.append(f"[{role}]: {content[:150]}...")
structured = "\n".join(summary_parts[:10])
if summary_parts:
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
if structured:
# Apply anti-injection prefix
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
if self._used_previous_summary:
summary = (
f"[Iterative conversation summary - update with new activity{focus_hint}]\n"
+ "\n".join(summary_parts[:15])
)
else:
summary = (
f"[Compacted conversation history - summarize key points{focus_hint}]\n"
+ "\n".join(summary_parts[:10])
)
# Handle iterative update if enabled
if self.config.iterative_update and self._previous_summary:
structured = self._merge_summaries(self._previous_summary, structured)
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
# Apply anti-injection prefix if configured
if hasattr(self.config, 'compaction_prefix') and self.config.compaction_prefix:
summary = f"{self.config.compaction_prefix}\n\n{summary}"
# Store for next iteration
if self.config.iterative_update:
self._previous_summary = structured
summary_msg = {
result.append({
"role": "system",
"content": summary,
"content": prefixed,
"_compacted": True,
"_original_count": len(to_summarize),
"_iterative": self._used_previous_summary,
"_focus_topic": focus_topic
}
result.append(summary_msg)
# Update tracking for future iterative summarization
self._previous_summary = summary
self._previous_summary_global_idx = len(messages)
"_original_count": len(older),
"_anti_injection": True,
})
result.extend(recent)
return result
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
async def _llm_summarize_async(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Use LLM to intelligently summarize older messages.
This method invokes the agent's LLM to create a meaningful summary
that preserves key facts, identifiers, and the user's intent.
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
"""
self._used_previous_summary = False
result = []
# Keep system messages
@@ -586,71 +436,33 @@ class ContextCompactor:
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Determine what to summarize based on iterative settings
total_original_messages = len(messages)
if (self.config.enable_iterative_summary and
self._previous_summary and
total_original_messages > self._previous_summary_global_idx):
# Iterative: summarize only new messages since previous summary
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
older = other_msgs[-messages_since_summary:-self.preserve_recent]
else:
older = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
self._used_previous_summary = False
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
if older and self.llm_summarize_fn:
try:
# Format messages for summarization using protocol
if self.message_formatter:
history_text = self.message_formatter.format_for_summary(older)
else:
# Fallback to basic formatting if no protocol implementation
history_text = str(older)
# Format messages for summarization
history_text = self._format_messages_for_summary(older)
# Create summarization prompt that preserves important information
focus_hint = f" Focus especially on: {focus_topic}." if focus_topic else ""
iterative_hint = ""
if self._used_previous_summary and self._previous_summary:
iterative_hint = f"\n\n[Previous Summary]: {self._previous_summary}\n[New Activity to Add]:"
prompt = (
"Summarise the following conversation history. Preserve verbatim: "
"all file paths, IDs, hashes, URLs, task references, error messages, "
"tool outputs, and the user's requests. Be concise but complete. "
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
f"{iterative_hint}\n\n{history_text}"
"Focus on facts and actions taken, not general conversation.\n\n"
f"{history_text}"
)
# Call the LLM for summarization
summary = await self.llm_summarize_fn(prompt)
# Add the LLM-generated summary as a system message
summary_content = summary
if self._used_previous_summary:
summary_content = f"[Iterative conversation summary]\n{summary}"
else:
summary_content = f"[Previous conversation summary]\n{summary}"
result.append({
"role": "system",
"content": summary_content,
"content": f"[Previous conversation summary]\n{summary}",
"_compacted": True,
"_original_count": len(older),
"_llm_generated": True,
"_iterative": self._used_previous_summary,
"_focus_topic": focus_topic
})
# Update tracking for future iterative summarization
self._previous_summary = summary
self._previous_summary_global_idx = len(messages)
except Exception as e:
# Fallback to naive summarization if LLM call fails
import logging
@@ -694,41 +506,133 @@ class ContextCompactor:
"content": summary,
"_compacted": True,
"_original_count": len(older),
"_fallback": True,
})
result.extend(recent)
return result
def _format_messages_for_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Delegate message formatting to protocol implementation.
"""Format messages for LLM summarization."""
formatted = []
for i, msg in enumerate(messages):
role = msg.get("role", "unknown")
content = msg.get("content", "")
# Handle tool calls
if msg.get("tool_calls"):
tool_calls = msg.get("tool_calls", [])
tool_names = [tc.get("function", {}).get("name", "unknown") for tc in tool_calls]
formatted.append(f"{i+1}. {role}: Called tools: {', '.join(tool_names)}")
# Handle content
if isinstance(content, str) and content.strip():
# Truncate very long content but preserve structure
if len(content) > 1000:
content = content[:800] + "...[truncated]..." + content[-200:]
formatted.append(f"{i+1}. {role}: {content}")
elif isinstance(content, list):
# Handle multi-part content
parts = []
for part in content[:3]: # Limit to first 3 parts
if isinstance(part, dict):
text = part.get("text", "")
if text:
parts.append(text[:200] + "..." if len(text) > 200 else text)
if parts:
formatted.append(f"{i+1}. {role}: {' | '.join(parts)}")
# Handle tool results with IDs
if msg.get("tool_call_id"):
tool_id = msg.get("tool_call_id", "")
formatted.append(f"{i+1}. {role} (tool {tool_id}): {str(content)[:500]}...")
If no formatter is provided, returns basic string representation.
"""
if self.message_formatter:
return self.message_formatter.format_for_summary(messages)
return str(messages)
return "\n".join(formatted)
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Delegate structured summary building to protocol implementation.
Build a structured summary using the configured template.
If no summary builder is provided, returns basic summary.
Args:
messages: Messages to summarize
Returns:
Structured summary string
"""
if self.summary_builder:
return self.summary_builder.build_structured_summary(messages)
return f"Summary of {len(messages)} messages"
# Extract information from messages
active_task = "No specific task identified"
completed = []
in_progress = []
pending = []
files = set()
remaining = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
if not isinstance(content, str):
continue
content_lower = content.lower()
# Extract file paths
file_matches = re.findall(r'[\w/\.\-]+\.[a-zA-Z]{1,4}', content)
files.update(file_matches[:5]) # Limit to 5 files
# Categorize content based on keywords and role
content_snippet = content[:200] + ("..." if len(content) > 200 else "")
if role == "user":
if any(word in content_lower for word in ["please", "can you", "help"]) or re.search(r'\bdo\b', content_lower):
active_task = content_snippet
elif "?" in content:
pending.append(content_snippet)
elif role == "assistant":
if any(word in content_lower for word in ["completed", "done", "finished"]):
completed.append(content_snippet)
elif any(word in content_lower for word in ["working", "processing", "analyzing"]):
in_progress.append(content_snippet)
elif any(word in content_lower for word in ["will", "plan to", "next"]):
remaining.append(content_snippet)
elif role == "tool":
# Tool results are generally completed actions
tool_name = msg.get("name", "unknown")
completed.append(f"Tool {tool_name}: {content_snippet}")
# Format using template
return SUMMARY_TEMPLATE.format(
active_task=active_task,
completed="\n".join(f"- {item}" for item in completed[:3]) or "None identified",
in_progress="\n".join(f"- {item}" for item in in_progress[:3]) or "None identified",
pending="\n".join(f"- {item}" for item in pending[:3]) or "None identified",
files=", ".join(list(files)[:5]) or "None mentioned",
remaining="\n".join(f"- {item}" for item in remaining[:3]) or "None identified"
)
def _merge_summaries(self, previous: str, current: str) -> str:
"""
Delegate summary merging to protocol implementation.
Merge previous summary with current one for iterative updates.
If no summary builder is provided, returns current summary only.
Args:
previous: Previous summary content
current: Current summary content
Returns:
Merged summary
"""
if self.summary_builder:
return self.summary_builder.merge_summaries(previous, current)
return current
# Simple merge strategy: prioritize current but preserve unique info from previous
if not previous:
return current
# Preserve context but avoid excessive growth
MAX_PREVIOUS_LENGTH = 500
if len(previous) > MAX_PREVIOUS_LENGTH:
truncated_previous = previous[:MAX_PREVIOUS_LENGTH]
return f"{current}\n\n[Previous context]: {truncated_previous}..."
else:
return f"{current}\n\n[Previous context]: {previous}"
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Get statistics about messages."""
@@ -50,28 +50,3 @@ class CompactionConfig:
compaction_prefix: str = COMPACTION_PREFIX # Override for custom framing
structured_template: bool = True # Emit Active Task / Remaining Work sections
iterative_update: bool = True # Merge previous summary on re-compaction
# Anti-thrashing protection
min_savings_pct: float = 10.0 # Skip if projected saving < 10% (0-100 scale)
max_consecutive_low_savings: int = 2 # Abort after N low-savings attempts
# Tool result optimization
tool_prune_before_summarise: bool = True # Deduplicate tool results before summarization
max_tool_result_size: int = 500 # Maximum size for tool results before pruning
# Iterative summarization
enable_iterative_summary: bool = True # Build on previous summaries instead of starting from scratch
def __post_init__(self):
"""Validate and normalize configuration values."""
# Normalize min_savings_pct to 0-100 scale if provided as ratio
if self.min_savings_pct < 1.0:
self.min_savings_pct *= 100.0
# Validate ranges
if not 0.0 <= self.min_savings_pct <= 100.0:
raise ValueError("min_savings_pct must be between 0 and 100")
if self.max_consecutive_low_savings < 0:
raise ValueError("max_consecutive_low_savings must be >= 0")
if self.max_tool_result_size <= 0:
raise ValueError("max_tool_result_size must be > 0")
@@ -1,73 +0,0 @@
"""
Compaction Protocols for PraisonAI Agents.
Protocol-driven interfaces for context compaction functionality.
"""
from typing import Protocol, List, Dict, Any, Tuple, Optional
class ToolResultPrunerProtocol(Protocol):
"""Protocol for pruning and deduplicating tool results."""
def prune(
self,
messages: List[Dict[str, Any]],
max_tool_result_size: int = 500
) -> Tuple[List[Dict[str, Any]], int]:
"""
Prune and deduplicate tool results to reduce token usage.
Args:
messages: List of messages to process
max_tool_result_size: Maximum size for tool results before truncation
Returns:
Tuple of (processed messages, number of tool results pruned)
"""
...
class MessageFormatterProtocol(Protocol):
"""Protocol for formatting messages for summarization."""
def format_for_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Format messages for LLM summarization.
Args:
messages: Messages to format
Returns:
Formatted string suitable for summarization
"""
...
class SummaryBuilderProtocol(Protocol):
"""Protocol for building structured summaries."""
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Build a structured summary from messages.
Args:
messages: Messages to summarize
Returns:
Structured summary string
"""
...
def merge_summaries(self, previous: str, current: str) -> str:
"""
Merge previous summary with current one for iterative updates.
Args:
previous: Previous summary content
current: Current summary content
Returns:
Merged summary
"""
...
@@ -18,16 +18,6 @@ class CompactionResult:
strategy_used: CompactionStrategy
summary: str = ""
# Anti-thrashing and optimization info
savings_pct: float = field(default=0.0, init=False) # Percentage of tokens saved (computed)
tool_results_pruned: int = 0 # Number of tool results deduplicated/pruned
previous_summary_reused: bool = False # Whether iterative summary was used
was_skipped_due_to_low_savings: bool = False # Whether compaction was skipped due to anti-thrashing
def __post_init__(self):
"""Initialize computed fields after construction."""
self.calculate_savings_pct()
@property
def tokens_saved(self) -> int:
"""Get number of tokens saved."""
@@ -40,13 +30,6 @@ class CompactionResult:
return 0.0
return self.compacted_tokens / self.original_tokens
def calculate_savings_pct(self) -> None:
"""Calculate and set the savings percentage."""
if self.original_tokens > 0:
self.savings_pct = (self.tokens_saved / self.original_tokens) * 100.0
else:
self.savings_pct = 0.0
@property
def was_compacted(self) -> bool:
"""Check if compaction occurred."""
@@ -62,9 +45,5 @@ class CompactionResult:
"messages_kept": self.messages_kept,
"strategy_used": self.strategy_used.value,
"compression_ratio": self.compression_ratio,
"savings_pct": self.savings_pct,
"tool_results_pruned": self.tool_results_pruned,
"previous_summary_reused": self.previous_summary_reused,
"was_skipped_due_to_low_savings": self.was_skipped_due_to_low_savings,
"summary": self.summary
}
@@ -26,7 +26,6 @@ __all__ = [
"OutputPreset",
"ExecutionPreset",
# Config classes
"LLMConfig",
"MemoryConfig",
"KnowledgeConfig",
"PlanningConfig",
@@ -41,7 +40,6 @@ __all__ = [
"HooksConfig",
"SkillsConfig",
"ToolSearchConfig",
"RuntimeConfig",
# Type aliases
"MemoryParam",
"KnowledgeParam",
@@ -49,7 +47,6 @@ __all__ = [
"ReflectionParam",
"GuardrailParam",
"WebParam",
"RuntimeParam",
# Resolver
"resolve",
"ArrayMode",
@@ -67,7 +64,6 @@ __all__ = [
"resolve_hooks",
"resolve_skills",
"resolve_routing",
"resolve_runtime",
# Parse utilities
"detect_url_scheme",
"is_path_like",
@@ -114,7 +110,6 @@ _MODULE_MAP = {
"WebSearchProvider": "feature_configs",
"OutputPreset": "feature_configs",
"ExecutionPreset": "feature_configs",
"LLMConfig": "feature_configs",
"MemoryConfig": "feature_configs",
"KnowledgeConfig": "feature_configs",
"PlanningConfig": "feature_configs",
@@ -129,14 +124,12 @@ _MODULE_MAP = {
"HooksConfig": "feature_configs",
"SkillsConfig": "feature_configs",
"ToolSearchConfig": "feature_configs",
"RuntimeConfig": "feature_configs",
"MemoryParam": "feature_configs",
"KnowledgeParam": "feature_configs",
"PlanningParam": "feature_configs",
"ReflectionParam": "feature_configs",
"GuardrailParam": "feature_configs",
"WebParam": "feature_configs",
"RuntimeParam": "feature_configs",
# param_resolver
"resolve": "param_resolver",
"ArrayMode": "param_resolver",
@@ -153,7 +146,6 @@ _MODULE_MAP = {
"resolve_hooks": "param_resolver",
"resolve_skills": "param_resolver",
"resolve_routing": "param_resolver",
"resolve_runtime": "feature_configs",
# parse_utils
"detect_url_scheme": "parse_utils",
"is_path_like": "parse_utils",
@@ -42,7 +42,6 @@ from ..agent.autonomy import AutonomyConfig
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..compaction.strategy import CompactionStrategy
from ..context.artifact_store import FileSystemArtifactStore
class MemoryBackend(str, Enum):
@@ -725,9 +724,6 @@ class ExecutionConfig:
# Retry settings
max_retry_limit: int = 2
retry_initial_delay: float = 1.0 # seconds
retry_backoff_factor: float = 2.0
retry_jitter: float = 0.1 # fraction of computed delay
# Tool call limits (loop protection)
max_tool_calls_per_turn: int = 10
@@ -739,16 +735,6 @@ class ExecutionConfig:
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
# "direct" runs in current process (legacy, less secure)
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
# multi-step tool pipelines in a single turn (intermediate results stay out
# of context). Opt-in and gated by code_tools_allow + the approval framework.
code_tools: bool = False
# Explicit per-run allow-list of tool names callable from code when
# code_tools is enabled. None/empty = no tools exposed (safe default).
code_tools_allow: Optional[List[str]] = None
# Rate limiter instance (consolidated from standalone rate_limiter param)
rate_limiter: Optional[Any] = None
@@ -780,7 +766,7 @@ class ExecutionConfig:
parallel_tool_calls: bool = False
def __post_init__(self) -> None:
"""Post-initialization processing with deprecation warnings and validation."""
"""Post-initialization processing with deprecation warnings."""
# Handle context_compaction serialization round-trip
if isinstance(self.context_compaction, dict):
from ..context.policy import ContextCompactionPolicy
@@ -788,47 +774,29 @@ class ExecutionConfig:
self.context_compaction
)
# Emit deprecation warning once per process for default behavior change.
# Walk the stack once — dataclass __init__ uses caller filename "<string>".
if self.context_compaction is False:
if getattr(ExecutionConfig, '_context_compaction_internal', False):
return
if getattr(ExecutionConfig, '_context_compaction_warned', False):
return
import warnings
import inspect
_INTERNAL = ('praisonaiagents', 'test_', '__pycache__')
frame = inspect.currentframe()
try:
caller = frame.f_back if frame else None
is_internal = False
while caller is not None:
if any(p in caller.f_code.co_filename for p in _INTERNAL):
is_internal = True
break
caller = caller.f_back
if is_internal:
ExecutionConfig._context_compaction_internal = True
return
warnings.warn(
"ExecutionConfig.context_compaction will default to True in the next "
"release for proactive context overflow protection. To disable, explicitly "
"set context_compaction=False. To use the new default early, set "
"context_compaction=True.",
DeprecationWarning,
stacklevel=3,
)
ExecutionConfig._context_compaction_warned = True
finally:
del frame
# Validate retry configuration parameters
if self.retry_initial_delay <= 0:
raise ValueError("ExecutionConfig.retry_initial_delay must be positive.")
if self.retry_backoff_factor < 1.0:
raise ValueError("ExecutionConfig.retry_backoff_factor must be >= 1.0.")
if self.retry_jitter < 0:
raise ValueError("ExecutionConfig.retry_jitter must be non-negative.")
# Emit deprecation warning for default behavior change
# Only warn if context_compaction was not explicitly set (i.e., using the default False)
import warnings
import inspect
frame = inspect.currentframe()
try:
# Check if this is being called from user code (not internal)
caller_frame = frame.f_back
if (caller_frame and
not any(path_part in caller_frame.f_code.co_filename
for path_part in ['praisonaiagents', 'test_', '__pycache__'])):
# This is being called from user code
if self.context_compaction is False:
warnings.warn(
"ExecutionConfig.context_compaction will default to True in the next "
"release for proactive context overflow protection. To disable, explicitly "
"set context_compaction=False. To use the new default early, set "
"context_compaction=True.",
DeprecationWarning,
stacklevel=3
)
finally:
del frame
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -837,14 +805,9 @@ class ExecutionConfig:
"max_rpm": self.max_rpm,
"max_execution_time": self.max_execution_time,
"max_retry_limit": self.max_retry_limit,
"retry_initial_delay": self.retry_initial_delay,
"retry_backoff_factor": self.retry_backoff_factor,
"retry_jitter": self.retry_jitter,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
"code_tools": self.code_tools,
"code_tools_allow": self.code_tools_allow,
"context_compaction": (
self.context_compaction.to_dict()
if hasattr(self.context_compaction, 'to_dict')
@@ -873,8 +836,6 @@ class ExecutionConfig:
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "docker"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
max_context_tokens=data.get("max_context_tokens", None),
max_budget=data.get("max_budget", None),
@@ -882,76 +843,12 @@ class ExecutionConfig:
)
@dataclass
class LLMConfig:
"""
Configuration for LLM model settings.
Groups all LLM-related parameters including model selection,
API settings, and fallback configuration.
Usage:
# With primary model only
Agent(llm_config=LLMConfig(model="gpt-4o"))
# With fallback chain
Agent(llm_config=LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="sk-...",
))
# With LiteLLM-style provider prefix
Agent(llm_config=LLMConfig(
model="anthropic/claude-3-5-sonnet",
fallback_models=["openai/gpt-4o", "openai/gpt-4o-mini"],
))
"""
# Primary model to use (required)
model: str
# Ordered fallback models for resilience (optional)
fallback_models: Optional[List[str]] = None
# API endpoint override (optional)
base_url: Optional[str] = None
# API key (optional, defaults to env vars)
api_key: Optional[str] = None
# Additional auth headers (optional)
auth: Optional[Dict[str, str]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"model": self.model,
"fallback_models": list(self.fallback_models) if self.fallback_models else None,
"base_url": self.base_url,
"api_key": self.api_key,
"auth": dict(self.auth) if self.auth else None,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "LLMConfig":
"""Create LLMConfig from dictionary."""
return cls(
model=data["model"],
fallback_models=list(data["fallback_models"]) if data.get("fallback_models") else None,
base_url=data.get("base_url"),
api_key=data.get("api_key"),
auth=dict(data["auth"]) if data.get("auth") else None,
)
@dataclass
class ToolConfig:
"""
Configuration for tool execution behavior.
Configuration for tool execution behavior including timeout, retry policy, parallel execution,
and artifact storage for large outputs.
Configuration for tool execution behavior including timeout, retry policy, and parallel execution.
Usage:
# Simple enable with defaults
@@ -964,12 +861,8 @@ class ToolConfig:
parallel=True,
))
# With artifact storage for large outputs
Agent(tool_config=ToolConfig(
output_limit=32000,
enable_artifacts=True,
artifact_retention_days=14,
))
# With timeout only
Agent(tool_config=ToolConfig(timeout=30))
"""
# Tool execution timeout in seconds
timeout: Optional[int] = None
@@ -980,26 +873,6 @@ class ToolConfig:
# Enable parallel execution of batched LLM tool calls
parallel: bool = False
# Tool output handling and artifact storage
output_limit: int = 16000 # Maximum bytes before spilling to artifact store
output_max_lines: Optional[int] = None # Maximum lines before spilling
output_direction: str = "both" # Truncation direction: "head", "tail", or "both"
enable_artifacts: bool = False # Whether to enable artifact storage (default False for backward compat)
artifact_retention_days: int = 7 # Days to retain artifacts before garbage collection
artifact_store: Optional[Any] = None # Custom artifact store instance
redact_secrets: bool = True # Whether to redact secrets from artifacts
def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if self.output_limit <= 0:
raise ValueError("tool_config.output_limit must be > 0")
if self.output_max_lines is not None and self.output_max_lines <= 0:
raise ValueError("tool_config.output_max_lines must be > 0 when provided")
if self.output_direction not in {"head", "tail", "both"}:
raise ValueError("tool_config.output_direction must be one of: 'head', 'tail', 'both'")
if self.artifact_retention_days < 0:
raise ValueError("tool_config.artifact_retention_days must be >= 0")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -1010,13 +883,6 @@ class ToolConfig:
else self.retry_policy
),
"parallel": self.parallel,
"output_limit": self.output_limit,
"output_max_lines": self.output_max_lines,
"output_direction": self.output_direction,
"enable_artifacts": self.enable_artifacts,
"artifact_retention_days": self.artifact_retention_days,
"artifact_store": self.artifact_store,
"redact_secrets": self.redact_secrets,
}
@classmethod
@@ -1035,13 +901,6 @@ class ToolConfig:
timeout=data.get("timeout"),
retry_policy=retry_policy,
parallel=data.get("parallel", False),
output_limit=data.get("output_limit", 16000),
output_max_lines=data.get("output_max_lines"),
output_direction=data.get("output_direction", "both"),
enable_artifacts=data.get("enable_artifacts", False),
artifact_retention_days=data.get("artifact_retention_days", 7),
artifact_store=data.get("artifact_store"),
redact_secrets=data.get("redact_secrets", True),
)
@@ -1230,8 +1089,8 @@ class MultiAgentOutputConfig:
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
verbose: int = 0
# Streaming output - False by default for sync multi-agent compatibility
stream: bool = False
# Streaming output
stream: bool = True
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -1406,49 +1265,79 @@ def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
"""
Resolve memory= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py with
special handling for backward compatibility.
Precedence: Instance > Config > Dict > String > Bool > Default
Args:
value: Memory parameter in any supported form
Returns:
MemoryConfig if enabled, None if disabled
Examples:
>>> resolve_memory(None) # Default: disabled
None
>>> resolve_memory(False) # Explicit disable
None
>>> resolve_memory(True) # Enable with defaults
MemoryConfig(backend='file')
>>> resolve_memory("redis") # String shorthand
MemoryConfig(backend='redis')
>>> resolve_memory({"backend": "sqlite", "user_id": "alice"}) # Dict
MemoryConfig(backend='sqlite', user_id='alice')
>>> resolve_memory(MemoryConfig(backend="postgres")) # Config passthrough
MemoryConfig(backend='postgres')
"""
from .param_resolver import resolve_memory as _resolve
# Default: disabled
if value is None:
return None
# Special case: handle string backends not in MEMORY_PRESETS
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return MemoryConfig()
# String: backend shorthand
if isinstance(value, str):
try:
# Try to resolve with canonical resolver first
return _resolve(value, MemoryConfig)
backend = MemoryBackend(value.lower())
except ValueError:
# Fall back to old behavior for custom/unknown backends
backend = value # Allow custom backend strings
return MemoryConfig(backend=backend)
# Dict: expand to config
if isinstance(value, dict):
# Handle backend enum conversion
backend = value.get("backend", MemoryBackend.FILE)
if isinstance(backend, str):
try:
backend = MemoryBackend(value.lower())
backend = MemoryBackend(backend.lower())
except ValueError:
backend = value # Allow custom backend strings
return MemoryConfig(backend=backend)
pass # Keep as string for custom backends
return MemoryConfig(
backend=backend,
user_id=value.get("user_id"),
session_id=value.get("session_id"),
auto_memory=value.get("auto_memory", False),
claude_memory=value.get("claude_memory", False),
db=value.get("db"),
config=value.get("config"),
)
# Special case: handle arbitrary instances with passthrough
if not isinstance(value, (type(None), bool, dict, list, tuple, MemoryConfig)):
# Try canonical resolver first
result = _resolve(value, MemoryConfig)
# If canonical resolver returns None for an instance, pass it through
if result is None and hasattr(value, '__class__'):
return value
return result
# Config: passthrough
if isinstance(value, MemoryConfig):
return value
# Default: use canonical resolver
return _resolve(value, MemoryConfig)
# Instance (MemoryManager): return as-is (caller handles)
# This is the highest precedence - user provided a pre-configured instance
return value
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
"""
Resolve knowledge= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
Precedence: Instance > Config > Array > Dict > String > Bool > Default
Args:
value: Knowledge parameter in any supported form
@@ -1456,91 +1345,164 @@ def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
Returns:
KnowledgeConfig if enabled, None if disabled
"""
from .param_resolver import resolve_knowledge as _resolve
return _resolve(value, KnowledgeConfig)
# Default: disabled
if value is None:
return None
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return KnowledgeConfig()
# String: single source
if isinstance(value, str):
return KnowledgeConfig(sources=[value])
# Array: list of sources
if isinstance(value, list):
return KnowledgeConfig(sources=value)
# Dict: expand to config
if isinstance(value, dict):
return KnowledgeConfig(
sources=value.get("sources", []),
embedder=value.get("embedder", "openai"),
embedder_config=value.get("embedder_config"),
chunking_strategy=value.get("chunking_strategy", ChunkingStrategy.SEMANTIC),
chunk_size=value.get("chunk_size", 1000),
chunk_overlap=value.get("chunk_overlap", 200),
retrieval_k=value.get("retrieval_k", 5),
retrieval_threshold=value.get("retrieval_threshold", 0.0),
rerank=value.get("rerank", False),
rerank_model=value.get("rerank_model"),
auto_retrieve=value.get("auto_retrieve", True),
)
# Config: passthrough
if isinstance(value, KnowledgeConfig):
return value
# Instance: return as-is
return value
def resolve_planning(value: PlanningParam) -> Optional[PlanningConfig]:
"""
Resolve planning= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_planning as _resolve
return _resolve(value, PlanningConfig)
"""Resolve planning= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return PlanningConfig()
if isinstance(value, dict):
return PlanningConfig(**value)
if isinstance(value, PlanningConfig):
return value
return value
def resolve_reflection(value: ReflectionParam) -> Optional[ReflectionConfig]:
"""
Resolve reflection= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_reflection as _resolve
return _resolve(value, ReflectionConfig)
"""Resolve reflection= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ReflectionConfig()
if isinstance(value, dict):
return ReflectionConfig(**value)
if isinstance(value, ReflectionConfig):
return value
return value
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
"""
Resolve guardrails= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py with
special handling for callable validators.
"""
from .param_resolver import resolve_guardrails as _resolve
# Special case: wrap callable in GuardrailConfig for backward compatibility
if callable(value) and not isinstance(value, type):
"""Resolve guardrails= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return GuardrailConfig()
if callable(value):
return GuardrailConfig(validator=value)
# Default: use canonical resolver
return _resolve(value, GuardrailConfig)
if isinstance(value, dict):
return GuardrailConfig(**value)
if isinstance(value, GuardrailConfig):
return value
return value
def resolve_web(value: WebParam) -> Optional[WebConfig]:
"""
Resolve web= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_web as _resolve
return _resolve(value, WebConfig)
"""Resolve web= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return WebConfig()
if isinstance(value, dict):
return WebConfig(**value)
if isinstance(value, WebConfig):
return value
return value
def resolve_output(value: OutputParam) -> Optional[OutputConfig]:
"""Resolve output= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = OutputPreset(value.lower())
return OutputConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return OutputConfig(**value)
if isinstance(value, OutputConfig):
return value
return value
def resolve_execution(value: ExecutionParam) -> Optional[ExecutionConfig]:
"""Resolve execution= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = ExecutionPreset(value.lower())
return ExecutionConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return ExecutionConfig(**value)
if isinstance(value, ExecutionConfig):
return value
return value
def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
"""
Resolve caching= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_caching as _resolve
return _resolve(value, CachingConfig)
"""Resolve caching= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return CachingConfig()
if isinstance(value, dict):
return CachingConfig(**value)
if isinstance(value, CachingConfig):
return value
return value
def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
"""
Resolve autonomy= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_autonomy as _resolve
return _resolve(value, AutonomyConfig)
"""Resolve autonomy= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return AutonomyConfig()
if isinstance(value, dict):
return AutonomyConfig(**value)
if isinstance(value, AutonomyConfig):
return value
return value
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
"""
Resolve tool_search= parameter following precedence ladder.
NOTE: This resolver has zero references in the codebase but is kept
for backward compatibility. Consider removing in a future version.
"""
# Simple implementation since it's unused
"""Resolve tool_search= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
@@ -1567,99 +1529,6 @@ def resolve_tools(value: ToolParam) -> Optional[ToolConfig]:
return value
@dataclass
class RuntimeConfig:
"""
Configuration for agent runtime capabilities and requirements.
Used to declare what capabilities an agent requires and validate
against runtime implementations at config/selection time.
Usage:
# Agent that requires native hooks and streaming
Agent(
instructions="...",
runtime=RuntimeConfig(
required_capabilities={"native_hooks", "streaming_deltas"}
)
)
# Agent with runtime preference
Agent(
runtime=RuntimeConfig(
preferred_runtime="native",
required_capabilities={"tool_loop", "mcp_tools"},
fallback_allowed=True
)
)
"""
# Required capabilities for this agent (capability names or enum values)
required_capabilities: Optional[Union[List[str], FrozenSet[str], List[Any], FrozenSet[Any]]] = None
# Preferred runtime implementation name
preferred_runtime: Optional[str] = None
# Whether to allow fallback to other runtimes if preferred is unavailable
fallback_allowed: bool = True
# Fail fast validation (validate at agent creation vs first execution)
validate_on_creation: bool = True
# Additional runtime metadata/hints
metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Normalize required_capabilities to a list."""
if self.required_capabilities is not None:
if isinstance(self.required_capabilities, str):
self.required_capabilities = [self.required_capabilities]
else:
try:
self.required_capabilities = list(self.required_capabilities)
except TypeError:
self.required_capabilities = [self.required_capabilities]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
req_caps = None
if self.required_capabilities:
req_caps = [
cap.name.lower() if hasattr(cap, "name") else str(cap)
for cap in self.required_capabilities
]
return {
"required_capabilities": req_caps,
"preferred_runtime": self.preferred_runtime,
"fallback_allowed": self.fallback_allowed,
"validate_on_creation": self.validate_on_creation,
"metadata": self.metadata,
}
# Type aliases for runtime parameters
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
"""Resolve runtime= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return RuntimeConfig()
if isinstance(value, str):
return RuntimeConfig(preferred_runtime=value)
if isinstance(value, (list, set, tuple, frozenset)):
return RuntimeConfig(required_capabilities=list(value))
if isinstance(value, dict):
return RuntimeConfig(**value)
if isinstance(value, RuntimeConfig):
return value
raise TypeError(
f"Invalid runtime parameter type: {type(value).__name__}. "
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
)
__all__ = [
# Enums
"MemoryBackend",
@@ -1687,7 +1556,6 @@ __all__ = [
"SkillsConfig",
"AutonomyConfig",
"ToolSearchConfig",
"RuntimeConfig",
# Config classes (Multi-Agent)
"MultiAgentHooksConfig",
"MultiAgentOutputConfig",
@@ -1710,7 +1578,6 @@ __all__ = [
"AutonomyParam",
"ToolSearchParam",
"ToolParam",
"RuntimeParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
@@ -1718,9 +1585,10 @@ __all__ = [
"resolve_reflection",
"resolve_guardrails",
"resolve_web",
"resolve_output",
"resolve_execution",
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
"resolve_tools",
"resolve_runtime",
]
@@ -83,7 +83,6 @@ class ConfigValidationError(Exception):
# Global config cache (lazy loaded)
_config_cache: Optional[Dict[str, Any]] = None
_config_loaded: bool = False
_defaults_has_values: Optional[bool] = None
def _default_project_plugins_dir():
@@ -419,27 +418,11 @@ def get_enabled_plugins() -> Optional[List[str]]:
return None # All plugins enabled
def _defaults_has_any_values() -> bool:
"""Return True if config file defines any defaults (cached after first check)."""
global _defaults_has_values
if _defaults_has_values is None:
defaults = get_defaults_config()
_defaults_has_values = any(
getattr(defaults, key, None) is not None
for key in (
"model", "base_url", "memory", "knowledge", "planning",
"reflection", "web", "output", "execution", "caching", "autonomy",
)
)
return _defaults_has_values
def clear_config_cache() -> None:
"""Clear the config cache (for testing)."""
global _config_cache, _config_loaded, _defaults_has_values
global _config_cache, _config_loaded
_config_cache = None
_config_loaded = False
_defaults_has_values = None
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
@@ -620,8 +603,6 @@ def apply_config_defaults(
if explicit_value is not None:
# If True and we have config defaults, merge them
if explicit_value is True:
if not _defaults_has_any_values():
return explicit_value
config_defaults = get_default(param_name)
if config_defaults and isinstance(config_defaults, dict):
if config_class:
@@ -631,10 +612,6 @@ def apply_config_defaults(
return config_defaults
return explicit_value
# Fast path: no config defaults file values — skip lookups
if not _defaults_has_any_values():
return None
# Check if config has defaults for this param
config_defaults = get_default(param_name)
if config_defaults is None:
@@ -239,12 +239,12 @@ EXECUTION_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
WEB_PRESETS: Dict[str, Dict[str, Any]] = {
# Providers - fetch disabled by default for OpenAI model compatibility
"duckduckgo": {"search": True, "fetch": False, "search_provider": "duckduckgo"},
"tavily": {"search": True, "fetch": False, "search_provider": "tavily"},
"google": {"search": True, "fetch": False, "search_provider": "google"},
"bing": {"search": True, "fetch": False, "search_provider": "bing"},
"serper": {"search": True, "fetch": False, "search_provider": "serper"},
# Providers
"duckduckgo": {"search": True, "fetch": True, "search_provider": "duckduckgo"},
"tavily": {"search": True, "fetch": True, "search_provider": "tavily"},
"google": {"search": True, "fetch": True, "search_provider": "google"},
"bing": {"search": True, "fetch": True, "search_provider": "bing"},
"serper": {"search": True, "fetch": True, "search_provider": "serper"},
# Modes
"search_only": {"search": True, "fetch": False},
"fetch_only": {"search": False, "fetch": True},
@@ -322,8 +322,8 @@ CACHING_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
MULTI_AGENT_OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"verbose": {"verbose": 2, "stream": False},
"minimal": {"verbose": 1, "stream": False},
"verbose": {"verbose": 2, "stream": True},
"minimal": {"verbose": 1, "stream": True},
"silent": {"verbose": 0, "stream": False},
}
@@ -180,7 +180,6 @@ __all__ = [
"TerminalLoggerProtocol",
"compute_checksum",
"generate_summary",
"FileSystemArtifactStore",
# Context Compaction Policy
"ContextCompactionPolicy",
"ContextCompactionPolicyProtocol",
@@ -296,11 +295,6 @@ def __getattr__(name: str):
from . import artifacts
return getattr(artifacts, name)
# FileSystemArtifactStore
if name == "FileSystemArtifactStore":
from .artifact_store import FileSystemArtifactStore
return FileSystemArtifactStore
# Session Context Tracking (Agno pattern)
if name in ("SessionContextTracker", "SessionState"):
from . import session_tracker
@@ -1,446 +0,0 @@
"""
Concrete implementation of ArtifactStoreProtocol for filesystem storage.
This module provides a filesystem-based artifact store that persists
tool outputs to disk when they exceed configured size limits.
"""
import os
import re
import json
import time
import uuid
import hashlib
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from .artifacts import (
ArtifactRef,
ArtifactMetadata,
ArtifactStoreProtocol,
GrepMatch,
compute_checksum,
generate_summary,
)
class FileSystemArtifactStore:
"""
Filesystem-based artifact storage implementation.
Stores artifacts under ~/.praisonai/artifacts/{agent_id}/{run_id}/
with content-addressed naming using SHA256 hashes.
"""
def __init__(
self,
base_dir: Optional[str] = None,
retention_days: int = 7,
redact_secrets: bool = True,
):
"""
Initialize the filesystem artifact store.
Args:
base_dir: Base directory for storage (default: ~/.praisonai/artifacts)
retention_days: Days to retain artifacts before GC
redact_secrets: Whether to redact secrets from stored content
"""
if base_dir:
self.base_dir = Path(base_dir)
else:
# Use standard PraisonAI data directory
home = Path.home()
self.base_dir = home / ".praisonai" / "artifacts"
self.retention_days = retention_days
self.redact_secrets = redact_secrets
self._secret_patterns = [
r'(?i)(api[_-]?key|apikey)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(secret|password|passwd|pwd)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(token|bearer)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
r'ghp_[a-zA-Z0-9]{36}', # GitHub tokens
]
# Ensure base directory exists
self.base_dir.mkdir(parents=True, exist_ok=True)
def store(
self,
content: Any,
metadata: ArtifactMetadata,
) -> ArtifactRef:
"""
Store content as an artifact.
Args:
content: The content to store (will be serialized)
metadata: Metadata about the artifact
Returns:
ArtifactRef pointing to the stored artifact
"""
# Serialize content and prepare for redaction
if isinstance(content, (str, bytes)):
content_str = content if isinstance(content, str) else None
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
mime_type = "text/plain"
else:
# JSON serialize structured data
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
mime_type = "application/json"
# Redact secrets if enabled (for text and JSON content)
if self.redact_secrets and content_str:
for pattern in self._secret_patterns:
content_str = re.sub(pattern, "[REDACTED]", content_str)
content_bytes = content_str.encode("utf-8")
# Compute checksum
checksum = compute_checksum(content_bytes)
# Generate artifact ID
artifact_id = f"{checksum[:12]}_{uuid.uuid4().hex[:8]}"
# Create directory structure
artifact_dir = self.base_dir / metadata.agent_id / metadata.run_id
artifact_dir.mkdir(parents=True, exist_ok=True)
# Write content to file
artifact_path = artifact_dir / f"{artifact_id}.artifact"
artifact_path.write_bytes(content_bytes)
# Write metadata
meta_path = artifact_dir / f"{artifact_id}.meta.json"
meta_data = metadata.to_dict()
meta_data["checksum"] = checksum
meta_data["size_bytes"] = len(content_bytes)
meta_data["mime_type"] = mime_type
meta_data["created_at"] = time.time()
meta_path.write_text(json.dumps(meta_data, indent=2))
# Generate summary (use redacted content if available)
summary_source = content_str if content_str else str(content)
summary = generate_summary(summary_source, max_chars=200)
# Create and return reference
ref = ArtifactRef(
path=str(artifact_path),
summary=summary,
size_bytes=len(content_bytes),
mime_type=mime_type,
checksum=checksum,
created_at=meta_data["created_at"],
artifact_id=artifact_id,
agent_id=metadata.agent_id,
run_id=metadata.run_id,
tool_name=metadata.tool_name,
turn_id=metadata.turn_id,
)
logging.debug(f"Stored artifact {artifact_id} ({len(content_bytes)} bytes) at {artifact_path}")
return ref
def _validate_artifact_path(self, path_or_ref: Union[Path, ArtifactRef]) -> Path:
"""Validate that path is within the artifact store's base directory."""
if isinstance(path_or_ref, ArtifactRef):
path = Path(path_or_ref.path)
else:
path = path_or_ref
resolved = path.expanduser().resolve()
base_resolved = self.base_dir.expanduser().resolve()
# Check if path is within base directory
try:
resolved.relative_to(base_resolved)
except ValueError:
raise PermissionError(f"Artifact path is outside configured store: {path}")
# Validate extension
if resolved.suffix != ".artifact":
raise ValueError(f"Invalid artifact file extension: {path}")
return resolved
def load(self, ref: ArtifactRef) -> Any:
"""
Load full content from an artifact.
Args:
ref: Reference to the artifact
Returns:
The deserialized content
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content_bytes = path.read_bytes()
# Verify checksum if provided
if ref.checksum:
actual_checksum = compute_checksum(content_bytes)
if actual_checksum != ref.checksum:
raise ValueError(f"Checksum mismatch for artifact {ref.artifact_id}")
# Deserialize based on mime type
if ref.mime_type == "application/json":
return json.loads(content_bytes.decode("utf-8"))
else:
return content_bytes.decode("utf-8")
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the last N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the last N lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
content_lines = content.splitlines()
if len(content_lines) <= lines:
return content
return "\n".join(content_lines[-lines:])
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the first N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the first N lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
content_lines = content.splitlines()
if len(content_lines) <= lines:
return content
return "\n".join(content_lines[:lines])
def grep(
self,
ref: ArtifactRef,
pattern: str,
context_lines: int = 2,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search for pattern in artifact content.
Args:
ref: Reference to the artifact
pattern: Regex pattern to search for
context_lines: Number of context lines before/after match
max_matches: Maximum number of matches to return
Returns:
List of GrepMatch objects
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
matches = []
pattern_re = re.compile(pattern, re.IGNORECASE)
for i, line in enumerate(lines):
if pattern_re.search(line):
# Get context lines
start_idx = max(0, i - context_lines)
end_idx = min(len(lines), i + context_lines + 1)
match = GrepMatch(
line_number=i + 1, # 1-indexed
line_content=line,
context_before=lines[start_idx:i],
context_after=lines[i + 1:end_idx],
)
matches.append(match)
if len(matches) >= max_matches:
break
return matches
def chunk(
self,
ref: ArtifactRef,
start_line: int = 1,
end_line: Optional[int] = None,
) -> str:
"""
Get a chunk of lines from an artifact.
Args:
ref: Reference to the artifact
start_line: Starting line number (1-indexed)
end_line: Ending line number (inclusive), None for end of file
Returns:
String containing the requested lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
# Convert to 0-indexed
start_idx = max(0, start_line - 1)
end_idx = end_line if end_line is None else end_line
return "\n".join(lines[start_idx:end_idx])
def delete(self, ref: ArtifactRef) -> bool:
"""
Delete an artifact.
Args:
ref: Reference to the artifact
Returns:
True if deleted successfully
"""
path = self._validate_artifact_path(Path(ref.path))
meta_path = path.with_suffix(".meta.json")
deleted = False
if path.exists():
path.unlink()
deleted = True
if meta_path.exists():
meta_path.unlink()
return deleted
def list_artifacts(
self,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
tool_name: Optional[str] = None,
) -> List[ArtifactRef]:
"""
List artifacts matching filters.
Args:
run_id: Filter by run ID
agent_id: Filter by agent ID
tool_name: Filter by tool name
Returns:
List of matching ArtifactRef objects
"""
artifacts = []
# Determine search path based on filters
if agent_id and run_id:
search_path = self.base_dir / agent_id / run_id
if search_path.exists():
search_paths = [search_path]
else:
search_paths = []
elif agent_id:
agent_path = self.base_dir / agent_id
if agent_path.exists():
search_paths = [p for p in agent_path.iterdir() if p.is_dir()]
else:
search_paths = []
else:
# Search all - but respect run_id filter if provided
search_paths = []
for agent_dir in self.base_dir.iterdir():
if agent_dir.is_dir():
for run_dir in agent_dir.iterdir():
if run_dir.is_dir():
# If run_id filter provided, only include matching runs
if run_id is None or run_dir.name == run_id:
search_paths.append(run_dir)
# Search for artifacts
for dir_path in search_paths:
for meta_file in dir_path.glob("*.meta.json"):
try:
meta_data = json.loads(meta_file.read_text())
# Apply tool_name filter if specified
if tool_name and meta_data.get("tool_name") != tool_name:
continue
# Reconstruct artifact path
artifact_file = meta_file.with_suffix("").with_suffix(".artifact")
if not artifact_file.exists():
continue
# Create ArtifactRef
ref = ArtifactRef(
path=str(artifact_file),
summary="", # Not stored in meta, would need to regenerate
size_bytes=meta_data.get("size_bytes", 0),
mime_type=meta_data.get("mime_type", "application/octet-stream"),
checksum=meta_data.get("checksum", ""),
created_at=meta_data.get("created_at", 0),
artifact_id=artifact_file.stem,
agent_id=meta_data.get("agent_id", ""),
run_id=meta_data.get("run_id", ""),
tool_name=meta_data.get("tool_name"),
turn_id=meta_data.get("turn_id", 0),
)
artifacts.append(ref)
except Exception as e:
logging.debug(f"Error loading artifact metadata from {meta_file}: {e}")
# Sort by creation time (newest first)
artifacts.sort(key=lambda x: x.created_at, reverse=True)
return artifacts
def cleanup_old_artifacts(self, days: Optional[int] = None) -> int:
"""
Clean up artifacts older than retention period.
Args:
days: Days to retain (overrides default retention_days)
Returns:
Number of artifacts deleted
"""
days = self.retention_days if days is None else days
cutoff_time = time.time() - (days * 24 * 60 * 60)
deleted_count = 0
for artifact in self.list_artifacts():
if artifact.created_at < cutoff_time:
if self.delete(artifact):
deleted_count += 1
logging.debug(f"Deleted old artifact {artifact.artifact_id}")
return deleted_count
Loaded 100 of 546 files, more files were not shown because too many files have changed in this diff. Show more