mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
263
Commits
No files matched your search
@@ -15,7 +15,7 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
@@ -143,9 +143,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
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');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
@@ -186,7 +189,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).\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\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});
|
||||
@@ -295,9 +298,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
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');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -313,7 +319,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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
@@ -399,9 +405,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
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');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -417,7 +426,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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
@@ -453,9 +462,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
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');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
@@ -468,9 +480,65 @@ 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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# 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
|
||||
|
||||
@@ -151,26 +151,67 @@ 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.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('can_push_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -180,31 +221,40 @@ jobs:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
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: |
|
||||
# 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)
|
||||
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)"
|
||||
fi
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.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' && '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' }}
|
||||
${{ 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) || '' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
@@ -233,15 +283,9 @@ 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.
|
||||
|
||||
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.
|
||||
${{ 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-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.
|
||||
@@ -267,10 +311,7 @@ jobs:
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
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".
|
||||
${{ 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' }}
|
||||
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:*)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
@@ -22,8 +22,6 @@ 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,9 +36,6 @@ 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,9 +37,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Set up Git
|
||||
|
||||
@@ -48,9 +48,6 @@ 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
|
||||
|
||||
@@ -60,16 +60,16 @@ jobs:
|
||||
- name: Run PraisonAI integration tests
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -m pytest tests/integration/test_aiui_* -v --timeout=300
|
||||
python -m pytest tests/integration/test_aiui_* -v
|
||||
|
||||
- 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 --timeout=300
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v
|
||||
fi
|
||||
if [ -f tests/test_feature_sdk_backends.py ]; then
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v
|
||||
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 --timeout=600
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
@@ -0,0 +1,183 @@
|
||||
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: false
|
||||
|
||||
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 isMainPush = context.eventName === 'push';
|
||||
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' };
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (!isMainPush && 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.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
if (!labels.includes(LABEL)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
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();
|
||||
@@ -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:
|
||||
|
||||
@@ -26,11 +26,8 @@ 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
|
||||
@@ -48,11 +45,8 @@ 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
|
||||
@@ -449,11 +443,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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"
|
||||
@@ -37,10 +37,8 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,10 +13,8 @@ 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
|
||||
@@ -81,10 +79,8 @@ 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
|
||||
|
||||
@@ -33,10 +33,8 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,10 +45,8 @@ 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
|
||||
|
||||
@@ -88,6 +88,9 @@ issue_details.json
|
||||
.internal
|
||||
.praisonai
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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/
|
||||
@@ -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 under 4μ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 around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
| Avg Instantiation Time | **14 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ 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">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>
|
||||
@@ -240,6 +242,7 @@ 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>
|
||||
@@ -476,11 +479,16 @@ 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_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">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">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">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">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>
|
||||
@@ -489,15 +497,24 @@ 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_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">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">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_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</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 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>
|
||||
|
||||
@@ -515,10 +532,12 @@ 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>
|
||||
@@ -527,6 +546,7 @@ 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
|
||||
|
||||
@@ -609,7 +629,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) -> 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, tool_call_id: str = None, run_id: str = None) -> str</code>
|
||||
|
||||
# UI
|
||||
|
||||
@@ -698,7 +718,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -789,11 +809,17 @@ 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>
|
||||
@@ -834,6 +860,8 @@ 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>
|
||||
@@ -846,6 +874,9 @@ 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>
|
||||
@@ -876,17 +907,18 @@ 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 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 docker <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>
|
||||
@@ -914,6 +946,9 @@ 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>
|
||||
@@ -923,6 +958,11 @@ 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>
|
||||
@@ -987,6 +1027,9 @@ 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>
|
||||
@@ -1004,6 +1047,14 @@ 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>
|
||||
@@ -1066,6 +1117,8 @@ 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>
|
||||
@@ -1075,6 +1128,10 @@ 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>
|
||||
@@ -1083,6 +1140,7 @@ 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>
|
||||
@@ -1150,6 +1208,9 @@ 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>
|
||||
@@ -1302,7 +1363,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, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
|
||||
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 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
|
||||
|
||||
@@ -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.55" \
|
||||
"praisonai>=4.6.74" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.74" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.74" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ Demonstrates web search with consolidated params.
|
||||
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, WebConfig
|
||||
|
||||
# Basic: Enable web search with preset
|
||||
# Basic: Enable web search with preset (search only — fetch needs Claude)
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant with web access.",
|
||||
web="search_only", # Compatible across models without web_fetch
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
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"
|
||||
]
|
||||
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
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%}")
|
||||
@@ -6,10 +6,18 @@ It includes self-reflection for improving code quality and multiple specialized
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.tools import CodeInterpreter
|
||||
|
||||
# Initialize code execution tool
|
||||
code_interpreter = 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}"
|
||||
|
||||
# Custom code analysis tool
|
||||
def analyze_code_complexity(code: str) -> str:
|
||||
@@ -32,9 +40,7 @@ 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.",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
code_reviewer = Agent(
|
||||
@@ -52,7 +58,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=[code_interpreter]
|
||||
tools=[run_python_code]
|
||||
)
|
||||
|
||||
# Example tasks
|
||||
@@ -87,7 +93,7 @@ def demonstrate_code_agent():
|
||||
workflow = AgentTeam(
|
||||
agents=[code_writer, code_reviewer, code_executor],
|
||||
tasks=[write_task, review_task, execute_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential", output="silent"
|
||||
)
|
||||
|
||||
# Run the workflow
|
||||
@@ -106,10 +112,8 @@ general_code_agent = Agent(
|
||||
3. Explain code concepts
|
||||
4. Refactor code for better performance
|
||||
5. Add tests and documentation""",
|
||||
tools=[code_interpreter, analyze_code_complexity],
|
||||
reflection=True,
|
||||
|
||||
|
||||
tools=[run_python_code, analyze_code_complexity],
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,21 +41,25 @@ def calculate_statistics(numbers: list) -> str:
|
||||
"""Calculate basic statistics for a list of numbers."""
|
||||
if not numbers:
|
||||
return "No numbers provided"
|
||||
|
||||
n = len(numbers)
|
||||
mean = sum(numbers) / n
|
||||
sorted_nums = sorted(numbers)
|
||||
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)
|
||||
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 numbers) / n
|
||||
variance = sum((x - mean)**2 for x in nums) / n
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
|
||||
return f"""Statistics:
|
||||
- Count: {n}
|
||||
- Mean: {mean:.4f}
|
||||
- Median: {median:.4f}
|
||||
- Standard Deviation: {std_dev:.4f}
|
||||
- Min: {min(numbers)}
|
||||
- Max: {max(numbers)}"""
|
||||
- Min: {min(nums)}
|
||||
- Max: {max(nums)}"""
|
||||
|
||||
# Create specialized math agents
|
||||
calculation_agent = Agent(
|
||||
@@ -66,8 +70,7 @@ 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(
|
||||
@@ -82,8 +85,7 @@ 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(
|
||||
@@ -91,7 +93,8 @@ 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."
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
# Example workflow for solving complex problems
|
||||
@@ -126,7 +129,8 @@ 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="verbose"
|
||||
process="sequential",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
return workflow.start()
|
||||
@@ -148,8 +152,7 @@ math_assistant = Agent(
|
||||
Always show your work step by step.""",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
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())
|
||||
@@ -179,7 +179,7 @@ def main():
|
||||
|
||||
context = memory.build_context_for_task(
|
||||
task_descr="Explain AI alignment challenges",
|
||||
user_id="quality_demo_user",
|
||||
memory={"user_id": "quality_demo_user"},
|
||||
max_items=3
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/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,7 +6,6 @@ to different next steps based on output content.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with branching
|
||||
workflow = AgentFlow(
|
||||
@@ -36,16 +35,9 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
@@ -6,9 +6,8 @@ functions that return StepResult with stop_workflow=True.
|
||||
"""
|
||||
|
||||
from praisonaiagents import (
|
||||
Workflow, Task, WorkflowContext, StepResult
|
||||
AgentFlow, Task, WorkflowContext, StepResult
|
||||
)
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Custom validator that can stop the workflow
|
||||
def validate_data(context: WorkflowContext) -> StepResult:
|
||||
@@ -50,20 +49,13 @@ 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 = 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)
|
||||
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'))}")
|
||||
|
||||
print("\n=== Testing with valid data ===")
|
||||
workflow.variables["data"] = {"value": 42}
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
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')}")
|
||||
@@ -6,7 +6,6 @@ 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(
|
||||
@@ -30,18 +29,12 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
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")
|
||||
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")
|
||||
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
|
||||
combining their results with an aggregator agent.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
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, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
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`, ask Claude to rebase rather than resolving conflicts yourself on non-trivial logic:
|
||||
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:
|
||||
|
||||
```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,6 +755,20 @@ 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:
|
||||
@@ -797,20 +811,20 @@ git push origin master
|
||||
|
||||
**Task 2: Update install.sh and deploy**
|
||||
```bash
|
||||
# 1. Update install.sh
|
||||
# 1. Update install.sh / install.ps1
|
||||
vim /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
|
||||
|
||||
# 2. Test install.sh
|
||||
bash /Users/praison/praisonai-package/src/praisonai/scripts/install.sh --dry-run
|
||||
bash /Users/praison/praisonai-package/src/praisonai/scripts/test-install-smoke.sh
|
||||
|
||||
# 3. Commit and push
|
||||
git add /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
|
||||
# 3. Commit and push to PraisonAI main — auto-triggers praison.ai deploy
|
||||
git add src/praisonai/scripts/install.sh src/praisonai/scripts/install.ps1
|
||||
git commit -m "fix: update install script"
|
||||
git push origin main
|
||||
# deploy-install-scripts.yml → praison.ai CI → https://praison.ai/install.sh
|
||||
|
||||
# 4. Deploy website (if needed)
|
||||
cd /Users/praison/Sites/localhost/praisonai
|
||||
make upgrade
|
||||
# Manual re-deploy (optional):
|
||||
gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI
|
||||
```
|
||||
|
||||
**Task 3: Rollback deployment**
|
||||
|
||||
@@ -257,6 +257,21 @@ 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Parity Tracker (Python)
|
||||
|
||||
> **Categories:** 60 | **Documented:** 57 | **Parity:** 95.0%
|
||||
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
|
||||
|
||||
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
|
||||
|
||||
@@ -8,108 +8,86 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Feature Categories | 60 |
|
||||
| **Documented Categories** | **57** |
|
||||
| **Undocumented Categories** | **3** |
|
||||
| **Parity** | **95.0%** |
|
||||
| Feature Categories | 66 |
|
||||
| **Documented Categories** | **65** |
|
||||
| **Undocumented Categories** | **1** |
|
||||
| **Parity** | **98.5%** |
|
||||
|
||||
## Documented Categories
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 19 | 30 | 8367 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 3 | 625 |
|
||||
| ✅ Approval | 1 | 2 | 889 |
|
||||
| ✅ Agent | 20 | 40 | 10767 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 3 | 6 | 1386 |
|
||||
| ✅ Autonomy | 3 | 4 | 1146 |
|
||||
| ✅ Bots | 5 | 2 | 573 |
|
||||
| ✅ Chunking | 2 | 2 | 285 |
|
||||
| ✅ Auto Generation | 5 | 6 | 1637 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 21 | 6676 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 106 | 26315 |
|
||||
| ✅ Chunking | 2 | 2 | 288 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 3 | 13 | 3395 |
|
||||
| ✅ Conditions | 1 | 3 | 1086 |
|
||||
| ✅ Configuration | 2 | 7 | 1753 |
|
||||
| ✅ Context Management | 13 | 29 | 7576 |
|
||||
| ✅ Database | 1 | 31 | 3037 |
|
||||
| ✅ Code Execution | 2 | 13 | 3551 |
|
||||
| ✅ Conditions | 1 | 3 | 1085 |
|
||||
| ✅ Configuration | 3 | 7 | 2069 |
|
||||
| ✅ Context Management | 16 | 33 | 10206 |
|
||||
| ✅ Database | 1 | 40 | 5373 |
|
||||
| ✅ Deep Research | 8 | 2 | 437 |
|
||||
| ✅ Display | 6 | 2 | 1175 |
|
||||
| ✅ Embeddings | 6 | 23 | 1966 |
|
||||
| ✅ 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 |
|
||||
| ✅ Evaluation | 1 | 6 | 2539 |
|
||||
| ✅ Events | 1 | 2 | 764 |
|
||||
| ✅ Execution | 3 | 3 | 1099 |
|
||||
| ✅ Failover | 2 | 1 | 372 |
|
||||
| ✅ Files | 2 | 6 | 1361 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 7 | 18 | 6400 |
|
||||
| ✅ Guardrails | 4 | 4 | 1619 |
|
||||
| ✅ Handoffs | 11 | 6 | 2499 |
|
||||
| ✅ Hooks | 2 | 8 | 3100 |
|
||||
| ✅ Image | 1 | 11 | 1096 |
|
||||
| ✅ 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 |
|
||||
| ✅ Knowledge | 4 | 23 | 4282 |
|
||||
| ✅ LLM | 3 | 10 | 3674 |
|
||||
| ✅ Loops | 4 | 3 | 774 |
|
||||
| ✅ MCP | 1 | 51 | 10933 |
|
||||
| ✅ Memory | 6 | 16 | 5879 |
|
||||
| ✅ OCR | 2 | 1 | 226 |
|
||||
| ✅ Observability | 2 | 23 | 2652 |
|
||||
| ✅ 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 |
|
||||
| ✅ Query | 1 | 2 | 616 |
|
||||
| ✅ RAG | 5 | 14 | 2910 |
|
||||
| ✅ RAG | 5 | 15 | 3190 |
|
||||
| ✅ Realtime | 2 | 5 | 584 |
|
||||
| ✅ Reflection | 3 | 2 | 469 |
|
||||
| ✅ Retrieval | 2 | 5 | 941 |
|
||||
| ✅ Reflection | 3 | 2 | 477 |
|
||||
| ✅ Retrieval | 2 | 5 | 944 |
|
||||
| ✅ Routing | 1 | 2 | 597 |
|
||||
| ✅ Sandbox | 5 | 3 | 1002 |
|
||||
| ✅ Security | 1 | 1 | 875 |
|
||||
| ✅ Sessions | 2 | 8 | 2128 |
|
||||
| ✅ Skills | 5 | 5 | 2002 |
|
||||
| ✅ Tasks | 2 | 5 | 2590 |
|
||||
| ✅ Telemetry | 1 | 2 | 541 |
|
||||
| ✅ Sandbox | 5 | 6 | 2478 |
|
||||
| ✅ Security | 1 | 3 | 2550 |
|
||||
| ✅ Sessions | 4 | 11 | 3423 |
|
||||
| ✅ Skills | 6 | 9 | 2983 |
|
||||
| ✅ Tasks | 2 | 6 | 3064 |
|
||||
| ✅ Telemetry | 1 | 2 | 560 |
|
||||
| ✅ Templates | 1 | 7 | 1411 |
|
||||
| ✅ Tools | 4 | 97 | 17827 |
|
||||
| ✅ Tools | 12 | 118 | 25868 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 5 | 460 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ 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 |
|
||||
| ✅ Web | 3 | 6 | 1352 |
|
||||
| ✅ Workflows | 4 | 14 | 5810 |
|
||||
|
||||
## Undocumented Categories (Need Documentation)
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| ❌ 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)
|
||||
| ❌ Vector Store | 1 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2025-12-18 14:40:22
|
||||
**Iterations:** 100
|
||||
**Test:** Agent instantiation (without tools)
|
||||
**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")`
|
||||
|
||||
## Results
|
||||
## Agent Instantiation Time
|
||||
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
## Package Versions
|
||||
|
||||
| Package | Version |
|
||||
|---------|--------|
|
||||
| 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 |
|
||||
|---------|---------|
|
||||
| PraisonAI | 1.6.60 |
|
||||
| Agno | 2.4.2 |
|
||||
| OpenAI Agents SDK | 0.7.0 |
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
python benchmarks/simple_benchmark.py
|
||||
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
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
|
||||
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
|
||||
('model', 'openai/gpt-4o-mini', 'model="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 `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
|
||||
f.write('3. Use `model="gpt-4o-mini"` 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.",
|
||||
llm="gpt-4o-mini",
|
||||
model="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.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
model="openai/gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import argparse
|
||||
|
||||
|
||||
ITERATIONS = 100
|
||||
WARMUP = 10
|
||||
|
||||
|
||||
def sample_tool(city: Literal['nyc', 'sf']):
|
||||
@@ -23,8 +24,10 @@ def sample_tool(city: Literal['nyc', 'sf']):
|
||||
return 'sunny'
|
||||
|
||||
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS):
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
|
||||
"""Measure average instantiation time in microseconds."""
|
||||
for _ in range(warmup):
|
||||
create_fn()
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
@@ -48,11 +51,11 @@ def run_benchmark():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
|
||||
results['PraisonAI'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
results['PraisonAI (LiteLLM)'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
# Other frameworks for comparison
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/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,6 +114,9 @@ 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'),
|
||||
@@ -175,6 +178,11 @@ _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'),
|
||||
@@ -229,6 +237,7 @@ _LAZY_IMPORTS = {
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
|
||||
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
|
||||
|
||||
# Error hierarchy - structured exception handling
|
||||
@@ -298,6 +307,12 @@ _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'),
|
||||
@@ -321,9 +336,24 @@ _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'),
|
||||
@@ -473,7 +503,7 @@ _LAZY_IMPORTS = {
|
||||
'GatewayClientProtocol': ('praisonaiagents.gateway.protocols', 'GatewayClientProtocol'),
|
||||
'GatewayEvent': ('praisonaiagents.gateway.protocols', 'GatewayEvent'),
|
||||
'GatewayMessage': ('praisonaiagents.gateway.protocols', 'GatewayMessage'),
|
||||
'EventType': ('praisonaiagents.gateway.protocols', 'EventType'),
|
||||
'GatewayEventType': ('praisonaiagents.gateway.protocols', 'EventType'),
|
||||
'GatewayConfig': ('praisonaiagents.gateway.config', 'GatewayConfig'),
|
||||
'SessionConfig': ('praisonaiagents.gateway.config', 'SessionConfig'),
|
||||
|
||||
@@ -775,8 +805,12 @@ 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,32 +111,3 @@ 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}"
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
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,31 +13,8 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# 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,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,12 +64,41 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Add memory context if memory is enabled
|
||||
if self._memory_instance:
|
||||
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()
|
||||
# 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()
|
||||
|
||||
# Add learn context if learn is enabled (auto-inject when memory="learn")
|
||||
learn_context = self.get_learn_context()
|
||||
@@ -106,6 +112,60 @@ 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
|
||||
@@ -145,10 +205,7 @@ Your Goal: {self.goal}"""
|
||||
except ImportError:
|
||||
pass # Trust module not available, skip security instructions
|
||||
|
||||
# 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)
|
||||
# Note: Caching is done BEFORE session context injection to avoid cross-user leakage
|
||||
return system_prompt
|
||||
|
||||
def _build_response_format(self, schema_model):
|
||||
@@ -452,6 +509,17 @@ 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,
|
||||
@@ -869,7 +937,22 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
|
||||
|
||||
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):
|
||||
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):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Proactive Context Budget Management (default-on) ---
|
||||
@@ -933,7 +1016,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._execute_unified_chat_completion(
|
||||
final_response = self._chat_completion_with_retry(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -954,7 +1037,11 @@ Your Goal: {self.goal}"""
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except Exception:
|
||||
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
|
||||
# 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
|
||||
@@ -965,7 +1052,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._execute_unified_chat_completion(
|
||||
final_response = self._chat_completion_with_retry(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -1112,7 +1199,8 @@ 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
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -1123,9 +1211,34 @@ Your Goal: {self.goal}"""
|
||||
# Don't retry without actual credential rotation
|
||||
|
||||
elif classification.should_fallback_model:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
elif classification.is_retryable and classification.backoff_seconds > 0:
|
||||
if _retry_depth < 2: # Limit retry attempts
|
||||
@@ -1134,7 +1247,8 @@ 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
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -1142,7 +1256,10 @@ Your Goal: {self.goal}"""
|
||||
if classification.should_rotate_credential:
|
||||
user_message += " Credential rotation is not yet implemented."
|
||||
if classification.should_fallback_model:
|
||||
user_message += " Model fallback is not yet implemented."
|
||||
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."
|
||||
|
||||
# Create LLMError with classification context
|
||||
error = LLMError(
|
||||
@@ -1180,7 +1297,8 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
_retry_depth=0
|
||||
_retry_depth=0,
|
||||
_fallback_index=0
|
||||
):
|
||||
"""
|
||||
Handle LLM errors in async context using structured classification.
|
||||
@@ -1245,7 +1363,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
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -1256,9 +1374,39 @@ Your Goal: {self.goal}"""
|
||||
# Don't retry without actual credential rotation
|
||||
|
||||
elif classification.should_fallback_model:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
elif classification.is_retryable and classification.backoff_seconds > 0:
|
||||
if _retry_depth < 2: # Limit retry attempts
|
||||
@@ -1275,7 +1423,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
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -1283,7 +1431,10 @@ Your Goal: {self.goal}"""
|
||||
if classification.should_rotate_credential:
|
||||
user_message += " Credential rotation is not yet implemented."
|
||||
if classification.should_fallback_model:
|
||||
user_message += " Model fallback is not yet implemented."
|
||||
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."
|
||||
|
||||
# Create LLMError with classification context
|
||||
error = LLMError(
|
||||
@@ -1327,7 +1478,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
@@ -1451,7 +1602,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified async dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
@@ -1836,7 +1987,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) -> str:
|
||||
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
|
||||
"""
|
||||
Truncate tool output according to configured budget.
|
||||
|
||||
@@ -1845,6 +1996,7 @@ 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
|
||||
@@ -1853,7 +2005,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
return output
|
||||
|
||||
try:
|
||||
return self.context_manager.truncate_tool_output(tool_name, output)
|
||||
run_id = getattr(self, '_run_id', None)
|
||||
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool output truncation error: {e}")
|
||||
return output
|
||||
@@ -2496,8 +2649,9 @@ 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 custom LLMs during reflection, always use non-streaming to ensure complete responses
|
||||
use_stream = self.stream if not self._using_custom_llm else False
|
||||
# For reflection, always use non-streaming to ensure compatibility with sync adapters
|
||||
# and to avoid streaming complexity during regeneration process
|
||||
use_stream = 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 ""
|
||||
@@ -2830,7 +2984,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._execute_unified_achat_completion(
|
||||
response = await self._achat_completion_with_retry(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -3002,7 +3156,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._execute_unified_achat_completion(
|
||||
new_response = await self._achat_completion_with_retry(
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -3885,3 +4039,156 @@ 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,31 +15,8 @@ import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
# 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,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -405,6 +405,59 @@ 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.
|
||||
@@ -569,8 +622,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Execute with tool boundary enforcement
|
||||
response = self.agent.chat(full_prompt, tools=effective_tools)
|
||||
# Resolve runtime at turn-time instead of using construction-time pin
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -675,13 +730,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# 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))
|
||||
# Resolve runtime at turn-time for async execution
|
||||
response = await self._execute_with_runtime_resolution_async(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -817,7 +869,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
response = self.agent.chat(prompt, tools=effective_tools)
|
||||
# Resolve runtime at turn-time for tool function execution
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
|
||||
@@ -10,31 +10,8 @@ import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# 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,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -317,8 +294,9 @@ class MemoryMixin:
|
||||
return
|
||||
|
||||
try:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
if self._session_store is None:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
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,15 +5,10 @@ Provides safe code execution capabilities to agents via the sandbox framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
|
||||
|
||||
from ..sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxManager,
|
||||
SandboxResult,
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from ..sandbox import SandboxConfig, SandboxManager, SandboxResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,27 +21,33 @@ class SandboxMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract sandbox config from kwargs
|
||||
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
|
||||
sandbox = kwargs.pop('sandbox', None)
|
||||
|
||||
# Convert boolean True to default config
|
||||
if self.sandbox_config is True:
|
||||
self.sandbox_config = SandboxConfig.subprocess()
|
||||
if sandbox is True:
|
||||
from ..sandbox import SandboxConfig
|
||||
sandbox = SandboxConfig.subprocess()
|
||||
elif sandbox is False:
|
||||
sandbox = None
|
||||
|
||||
self.sandbox_config: Optional['SandboxConfig'] = sandbox
|
||||
|
||||
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
|
||||
@@ -57,7 +58,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Execute code safely in configured sandbox.
|
||||
|
||||
Args:
|
||||
@@ -79,6 +80,7 @@ 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)
|
||||
@@ -97,7 +99,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Synchronous wrapper for execute_code.
|
||||
|
||||
Args:
|
||||
@@ -117,7 +119,7 @@ class SandboxMixin:
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Run a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
@@ -139,6 +141,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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,21 +7,10 @@ Reuses existing telemetry/token_collector for token tracking.
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
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.
|
||||
@@ -179,45 +168,3 @@ 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
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
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,6 +14,7 @@ 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
|
||||
@@ -24,8 +25,61 @@ 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."""
|
||||
@@ -231,9 +285,10 @@ class ToolExecutionMixin:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# Loop guard check - prevent tool execution loops with graduated response
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
from ..escalation.loop_guard import GuardAction
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
|
||||
if decision.action == GuardAction.WARN:
|
||||
# Inject warning into tool result so LLM sees guidance
|
||||
@@ -245,7 +300,6 @@ 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,
|
||||
@@ -273,32 +327,159 @@ class ToolExecutionMixin:
|
||||
if blocked_result is not None:
|
||||
result = blocked_result
|
||||
else:
|
||||
# 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}
|
||||
# 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:
|
||||
with with_injection_context(state):
|
||||
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
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, treat as non-retryable unless specified
|
||||
else:
|
||||
# Success path - return the result
|
||||
break
|
||||
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)
|
||||
|
||||
# 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__}
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
# Apply prompt injection protection for external tools
|
||||
# Zero-cost for trusted tools, wraps external content in security markers
|
||||
@@ -310,28 +491,78 @@ class ToolExecutionMixin:
|
||||
try:
|
||||
result_str = str(result)
|
||||
|
||||
if self.context_manager:
|
||||
# Use context-aware truncation with configured budget
|
||||
truncated = self._truncate_tool_output(function_name, result_str)
|
||||
else:
|
||||
# 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}"
|
||||
# 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:
|
||||
truncated = result_str
|
||||
# Fallback to simple truncation
|
||||
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
|
||||
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}"
|
||||
else:
|
||||
truncated = self._truncate_tool_output(function_name, truncated, tool_call_id)
|
||||
|
||||
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)
|
||||
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()
|
||||
else:
|
||||
result = truncated
|
||||
except Exception as e:
|
||||
@@ -373,6 +604,15 @@ 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(),
|
||||
@@ -382,6 +622,7 @@ 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)
|
||||
@@ -394,11 +635,12 @@ class ToolExecutionMixin:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
# Record tool execution in loop guard
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
|
||||
self._loop_guard.record(function_name, arguments, is_success)
|
||||
loop_guard.record(function_name, arguments, is_success)
|
||||
# Handle warning injection for WARN decisions
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
decision = 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}"
|
||||
@@ -417,25 +659,29 @@ class ToolExecutionMixin:
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
# Emit tool call end with error
|
||||
# Emit tool call end with error for exceptions that escape the retry loop
|
||||
_duration_ms = (_time.time() - _tool_start_time) * 1000
|
||||
_trace_emitter.tool_call_end(self.name, function_name, None, _duration_ms, str(e))
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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'),
|
||||
@@ -471,10 +717,31 @@ 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'),
|
||||
@@ -507,6 +774,21 @@ 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:
|
||||
@@ -531,7 +813,7 @@ class ToolExecutionMixin:
|
||||
response=response,
|
||||
)
|
||||
|
||||
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None) -> dict:
|
||||
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = 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)
|
||||
@@ -546,13 +828,30 @@ class ToolExecutionMixin:
|
||||
tail_limit = int(max_field_chars * 0.15)
|
||||
head = value[:head_limit]
|
||||
tail = value[-tail_limit:] if tail_limit > 0 else ""
|
||||
result[key] = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
|
||||
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
|
||||
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)
|
||||
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars, tool_call_id)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._truncate_dict_fields(item, tool_name, max_field_chars) if isinstance(item, dict)
|
||||
self._truncate_dict_fields(item, tool_name, max_field_chars, tool_call_id) 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
|
||||
]
|
||||
@@ -584,12 +883,22 @@ 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:
|
||||
needs_approval = approve_all or tool_name in DEFAULT_DANGEROUS_TOOLS
|
||||
# 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
|
||||
)
|
||||
if needs_approval:
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
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,6 +34,22 @@ 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,
|
||||
@@ -78,12 +94,38 @@ 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, '_loop_guard') and self._loop_guard:
|
||||
self._loop_guard.reset_turn()
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
self._ensure_loop_guard().reset_turn()
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
# 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
|
||||
return await self._chat_via_cli_backend(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -40,6 +40,21 @@ 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
|
||||
|
||||
|
||||
@@ -245,7 +260,20 @@ Expected Output: {task.expected_output}."""
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
# 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)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
@@ -652,7 +680,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
_stream = _output_config.stream
|
||||
else:
|
||||
_verbose = 0
|
||||
_stream = True
|
||||
_stream = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve EXECUTION param using canonical resolver
|
||||
@@ -1028,6 +1056,65 @@ 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:
|
||||
@@ -1046,53 +1133,11 @@ 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):
|
||||
# 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
|
||||
# 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
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
@@ -1106,20 +1151,6 @@ 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:
|
||||
@@ -1334,52 +1365,11 @@ 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):
|
||||
# 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
|
||||
# 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
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
@@ -1389,20 +1379,6 @@ 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)
|
||||
|
||||
@@ -1993,6 +1969,8 @@ 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:
|
||||
@@ -2071,7 +2049,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@app.get(f"{path}/list")
|
||||
async def list_agents():
|
||||
async def list_agents(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {
|
||||
"agents": [
|
||||
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
|
||||
@@ -2086,6 +2066,8 @@ 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,3 +53,4 @@ class AgentAppConfig:
|
||||
workers: int = 1
|
||||
timeout: int = 60
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
api_key: Optional[str] = None
|
||||
@@ -74,11 +74,15 @@ 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,9 +194,13 @@ class ApprovalRegistry:
|
||||
|
||||
def is_yaml_approved(self, tool_name: str) -> bool:
|
||||
try:
|
||||
return tool_name in self._yaml_approved_tools.get()
|
||||
if tool_name not in self._yaml_approved_tools.get():
|
||||
return False
|
||||
except LookupError:
|
||||
return False
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Env-var check ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8,23 +8,7 @@ All implementations live in the praisonai wrapper package.
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .protocols import (
|
||||
BotOSProtocol, # noqa: F401
|
||||
BotProtocol,
|
||||
BotMessageProtocol,
|
||||
BotUserProtocol,
|
||||
BotChannelProtocol,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ChatCommandInfo,
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
pass
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
BotMessageProtocol,
|
||||
@@ -41,6 +25,31 @@ 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
|
||||
|
||||
@@ -62,4 +71,25 @@ __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,6 +91,16 @@ 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)
|
||||
@@ -101,6 +111,16 @@ 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"}:
|
||||
@@ -136,11 +156,17 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,651 @@
|
||||
"""
|
||||
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,6 +22,7 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
)
|
||||
@@ -30,6 +31,105 @@ 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."""
|
||||
|
||||
@@ -318,6 +418,80 @@ 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.
|
||||
@@ -331,6 +505,8 @@ 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
|
||||
@@ -341,6 +517,8 @@ 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."""
|
||||
@@ -353,6 +531,8 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +568,24 @@ 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)."""
|
||||
@@ -499,6 +697,33 @@ 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.
|
||||
@@ -613,6 +838,55 @@ 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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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,6 +170,17 @@ 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
|
||||
|
||||
@@ -194,6 +205,10 @@ 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)
|
||||
@@ -245,6 +260,15 @@ 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 {},
|
||||
@@ -321,6 +345,11 @@ 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,9 +92,25 @@ 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,6 +33,10 @@ __all__ = [
|
||||
"CompactionStrategy",
|
||||
# Results
|
||||
"CompactionResult",
|
||||
# Protocols
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
]
|
||||
|
||||
|
||||
@@ -54,4 +58,16 @@ 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,12 +5,13 @@ Manages context window by compacting messages when needed.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
|
||||
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:
|
||||
@@ -34,7 +35,10 @@ class ContextCompactor:
|
||||
preserve_system: bool = True,
|
||||
preserve_recent: int = 5,
|
||||
config: Optional[CompactionConfig] = None,
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = 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
|
||||
):
|
||||
"""
|
||||
Initialize the compactor.
|
||||
@@ -45,30 +49,43 @@ class ContextCompactor:
|
||||
strategy: Compaction strategy to use
|
||||
preserve_system: Keep system messages
|
||||
preserve_recent: Number of recent messages to preserve
|
||||
<<<<<<< HEAD
|
||||
config: Optional CompactionConfig to override defaults
|
||||
config: Optional CompactionConfig for advanced settings
|
||||
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
|
||||
"""
|
||||
# Initialize config first
|
||||
self.config = config or CompactionConfig()
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# Set instance attributes from config to ensure consistency
|
||||
self.max_tokens = self.config.max_tokens
|
||||
self.target_tokens = self.config.target_tokens
|
||||
self.strategy = strategy
|
||||
self.preserve_system = self.config.preserve_system
|
||||
self.preserve_recent = self.config.preserve_recent
|
||||
self.llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# Track previous summaries for iterative update
|
||||
# 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
|
||||
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."""
|
||||
@@ -96,12 +113,26 @@ 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."""
|
||||
return self.count_total_tokens(messages) > self.max_tokens
|
||||
"""
|
||||
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
|
||||
|
||||
def compact(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (synchronous version).
|
||||
@@ -111,31 +142,49 @@ 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:
|
||||
return messages, CompactionResult(
|
||||
result = 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(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
if self.llm_summarize_fn:
|
||||
# For sync calls with LLM function, we need to run async
|
||||
@@ -144,17 +193,17 @@ class ContextCompactor:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If in async context, fallback to naive summarization
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to create one
|
||||
compacted = asyncio.run(self._llm_summarize_async(messages))
|
||||
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
|
||||
except Exception:
|
||||
# Fallback to naive summarization if async fails
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
else:
|
||||
compacted = self._llm_summarize(messages)
|
||||
compacted = self._llm_summarize(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -163,49 +212,75 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
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]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> 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:
|
||||
return messages, CompactionResult(
|
||||
result = 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(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
compacted = await self._llm_summarize_async(messages)
|
||||
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -214,11 +289,35 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
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 = []
|
||||
@@ -244,11 +343,17 @@ class ContextCompactor:
|
||||
|
||||
# If still over limit, truncate more
|
||||
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
|
||||
# Remove oldest non-system message
|
||||
# Remove oldest message (respecting preserve_system setting)
|
||||
removed = False
|
||||
for i, msg in enumerate(result):
|
||||
if msg.get("role") != "system":
|
||||
# Skip system messages only if preserve_system is True
|
||||
if not (self.preserve_system and 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
|
||||
|
||||
@@ -360,72 +465,19 @@ class ContextCompactor:
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to summarize older messages with anti-injection framing.
|
||||
Use LLM to summarize older messages with iterative support.
|
||||
|
||||
Note: This implementation uses structured templates and anti-injection prefixes
|
||||
to prevent the model from treating summarized content as active instructions.
|
||||
"""
|
||||
result = []
|
||||
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.
|
||||
|
||||
# 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 []
|
||||
|
||||
if older:
|
||||
if self.config.structured_template:
|
||||
structured = self._build_structured_summary(older)
|
||||
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 structured:
|
||||
# Apply anti-injection prefix
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# 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}"
|
||||
|
||||
# Store for next iteration
|
||||
if self.config.iterative_update:
|
||||
self._previous_summary = structured
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": prefixed,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_anti_injection": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
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
|
||||
@@ -436,33 +488,169 @@ class ContextCompactor:
|
||||
|
||||
# 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]
|
||||
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:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
|
||||
if summary_parts:
|
||||
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
|
||||
|
||||
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])
|
||||
)
|
||||
|
||||
# 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}"
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
"content": summary,
|
||||
"_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)
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> 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
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
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:]
|
||||
|
||||
# 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
|
||||
|
||||
if older and self.llm_summarize_fn:
|
||||
try:
|
||||
# Format messages for summarization
|
||||
history_text = self._format_messages_for_summary(older)
|
||||
# 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)
|
||||
|
||||
# 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. "
|
||||
"Focus on facts and actions taken, not general conversation.\n\n"
|
||||
f"{history_text}"
|
||||
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
|
||||
f"{iterative_hint}\n\n{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": f"[Previous conversation summary]\n{summary}",
|
||||
"content": summary_content,
|
||||
"_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
|
||||
@@ -506,133 +694,41 @@ 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:
|
||||
"""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]}...")
|
||||
"""
|
||||
Delegate message formatting to protocol implementation.
|
||||
|
||||
return "\n".join(formatted)
|
||||
If no formatter is provided, returns basic string representation.
|
||||
"""
|
||||
if self.message_formatter:
|
||||
return self.message_formatter.format_for_summary(messages)
|
||||
return str(messages)
|
||||
|
||||
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary using the configured template.
|
||||
Delegate structured summary building to protocol implementation.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
If no summary builder is provided, returns basic summary.
|
||||
"""
|
||||
# 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"
|
||||
)
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.build_structured_summary(messages)
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def _merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
Delegate summary merging to protocol implementation.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
If no summary builder is provided, returns current summary only.
|
||||
"""
|
||||
# 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}"
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.merge_summaries(previous, current)
|
||||
return current
|
||||
|
||||
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get statistics about messages."""
|
||||
|
||||
@@ -50,3 +50,28 @@ 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")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
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,6 +18,16 @@ 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."""
|
||||
@@ -30,6 +40,13 @@ 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."""
|
||||
@@ -45,5 +62,9 @@ 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,6 +26,7 @@ __all__ = [
|
||||
"OutputPreset",
|
||||
"ExecutionPreset",
|
||||
# Config classes
|
||||
"LLMConfig",
|
||||
"MemoryConfig",
|
||||
"KnowledgeConfig",
|
||||
"PlanningConfig",
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"ToolSearchConfig",
|
||||
"RuntimeConfig",
|
||||
# Type aliases
|
||||
"MemoryParam",
|
||||
"KnowledgeParam",
|
||||
@@ -47,6 +49,7 @@ __all__ = [
|
||||
"ReflectionParam",
|
||||
"GuardrailParam",
|
||||
"WebParam",
|
||||
"RuntimeParam",
|
||||
# Resolver
|
||||
"resolve",
|
||||
"ArrayMode",
|
||||
@@ -64,6 +67,7 @@ __all__ = [
|
||||
"resolve_hooks",
|
||||
"resolve_skills",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
# Parse utilities
|
||||
"detect_url_scheme",
|
||||
"is_path_like",
|
||||
@@ -110,6 +114,7 @@ _MODULE_MAP = {
|
||||
"WebSearchProvider": "feature_configs",
|
||||
"OutputPreset": "feature_configs",
|
||||
"ExecutionPreset": "feature_configs",
|
||||
"LLMConfig": "feature_configs",
|
||||
"MemoryConfig": "feature_configs",
|
||||
"KnowledgeConfig": "feature_configs",
|
||||
"PlanningConfig": "feature_configs",
|
||||
@@ -124,12 +129,14 @@ _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",
|
||||
@@ -146,6 +153,7 @@ _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,6 +42,7 @@ 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):
|
||||
@@ -724,6 +725,9 @@ 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
|
||||
@@ -766,7 +770,7 @@ class ExecutionConfig:
|
||||
parallel_tool_calls: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization processing with deprecation warnings."""
|
||||
"""Post-initialization processing with deprecation warnings and validation."""
|
||||
# Handle context_compaction serialization round-trip
|
||||
if isinstance(self.context_compaction, dict):
|
||||
from ..context.policy import ContextCompactionPolicy
|
||||
@@ -774,29 +778,47 @@ class ExecutionConfig:
|
||||
self.context_compaction
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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.")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -805,6 +827,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,
|
||||
@@ -843,12 +868,76 @@ 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, and parallel execution.
|
||||
Configuration for tool execution behavior including timeout, retry policy, parallel execution,
|
||||
and artifact storage for large outputs.
|
||||
|
||||
Usage:
|
||||
# Simple enable with defaults
|
||||
@@ -861,8 +950,12 @@ class ToolConfig:
|
||||
parallel=True,
|
||||
))
|
||||
|
||||
# With timeout only
|
||||
Agent(tool_config=ToolConfig(timeout=30))
|
||||
# With artifact storage for large outputs
|
||||
Agent(tool_config=ToolConfig(
|
||||
output_limit=32000,
|
||||
enable_artifacts=True,
|
||||
artifact_retention_days=14,
|
||||
))
|
||||
"""
|
||||
# Tool execution timeout in seconds
|
||||
timeout: Optional[int] = None
|
||||
@@ -873,6 +966,26 @@ 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 {
|
||||
@@ -883,6 +996,13 @@ 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
|
||||
@@ -901,6 +1021,13 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -1089,8 +1216,8 @@ class MultiAgentOutputConfig:
|
||||
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
|
||||
verbose: int = 0
|
||||
|
||||
# Streaming output
|
||||
stream: bool = True
|
||||
# Streaming output - False by default for sync multi-agent compatibility
|
||||
stream: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -1265,79 +1392,49 @@ def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
|
||||
"""
|
||||
Resolve memory= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Dict > String > Bool > Default
|
||||
Delegates to the canonical resolver in param_resolver.py with
|
||||
special handling for backward compatibility.
|
||||
|
||||
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')
|
||||
"""
|
||||
# Default: disabled
|
||||
if value is None:
|
||||
return None
|
||||
from .param_resolver import resolve_memory as _resolve
|
||||
|
||||
# Bool: False = disabled, True = defaults
|
||||
if value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return MemoryConfig()
|
||||
|
||||
# String: backend shorthand
|
||||
# Special case: handle string backends not in MEMORY_PRESETS
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
backend = MemoryBackend(value.lower())
|
||||
# Try to resolve with canonical resolver first
|
||||
return _resolve(value, MemoryConfig)
|
||||
except ValueError:
|
||||
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):
|
||||
# Fall back to old behavior for custom/unknown backends
|
||||
try:
|
||||
backend = MemoryBackend(backend.lower())
|
||||
backend = MemoryBackend(value.lower())
|
||||
except ValueError:
|
||||
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"),
|
||||
)
|
||||
backend = value # Allow custom backend strings
|
||||
return MemoryConfig(backend=backend)
|
||||
|
||||
# Config: passthrough
|
||||
if isinstance(value, MemoryConfig):
|
||||
return value
|
||||
# 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
|
||||
|
||||
# Instance (MemoryManager): return as-is (caller handles)
|
||||
# This is the highest precedence - user provided a pre-configured instance
|
||||
return value
|
||||
# Default: use canonical resolver
|
||||
return _resolve(value, MemoryConfig)
|
||||
|
||||
|
||||
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
|
||||
"""
|
||||
Resolve knowledge= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Array > Dict > String > Bool > Default
|
||||
Delegates to the canonical resolver in param_resolver.py.
|
||||
Kept for backward compatibility with tests.
|
||||
|
||||
Args:
|
||||
value: Knowledge parameter in any supported form
|
||||
@@ -1345,164 +1442,91 @@ def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
|
||||
Returns:
|
||||
KnowledgeConfig if enabled, None if disabled
|
||||
"""
|
||||
# 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
|
||||
from .param_resolver import resolve_knowledge as _resolve
|
||||
return _resolve(value, KnowledgeConfig)
|
||||
|
||||
|
||||
def resolve_planning(value: PlanningParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_reflection(value: ReflectionParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
|
||||
"""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):
|
||||
"""
|
||||
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):
|
||||
return GuardrailConfig(validator=value)
|
||||
if isinstance(value, dict):
|
||||
return GuardrailConfig(**value)
|
||||
if isinstance(value, GuardrailConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
# Default: use canonical resolver
|
||||
return _resolve(value, GuardrailConfig)
|
||||
|
||||
|
||||
def resolve_web(value: WebParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_autonomy(value: AutonomyParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
|
||||
"""Resolve tool_search= parameter following precedence ladder."""
|
||||
"""
|
||||
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
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
@@ -1529,6 +1553,99 @@ 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",
|
||||
@@ -1556,6 +1673,7 @@ __all__ = [
|
||||
"SkillsConfig",
|
||||
"AutonomyConfig",
|
||||
"ToolSearchConfig",
|
||||
"RuntimeConfig",
|
||||
# Config classes (Multi-Agent)
|
||||
"MultiAgentHooksConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
@@ -1578,6 +1696,7 @@ __all__ = [
|
||||
"AutonomyParam",
|
||||
"ToolSearchParam",
|
||||
"ToolParam",
|
||||
"RuntimeParam",
|
||||
# Precedence ladder resolvers
|
||||
"resolve_memory",
|
||||
"resolve_knowledge",
|
||||
@@ -1585,10 +1704,9 @@ __all__ = [
|
||||
"resolve_reflection",
|
||||
"resolve_guardrails",
|
||||
"resolve_web",
|
||||
"resolve_output",
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
"resolve_tools",
|
||||
"resolve_runtime",
|
||||
]
|
||||
@@ -83,6 +83,7 @@ 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():
|
||||
@@ -418,11 +419,27 @@ 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
|
||||
global _config_cache, _config_loaded, _defaults_has_values
|
||||
_config_cache = None
|
||||
_config_loaded = False
|
||||
_defaults_has_values = None
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
|
||||
@@ -603,6 +620,8 @@ 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:
|
||||
@@ -612,6 +631,10 @@ 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
|
||||
"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"},
|
||||
# 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"},
|
||||
# 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": True},
|
||||
"minimal": {"verbose": 1, "stream": True},
|
||||
"verbose": {"verbose": 2, "stream": False},
|
||||
"minimal": {"verbose": 1, "stream": False},
|
||||
"silent": {"verbose": 0, "stream": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ __all__ = [
|
||||
"TerminalLoggerProtocol",
|
||||
"compute_checksum",
|
||||
"generate_summary",
|
||||
"FileSystemArtifactStore",
|
||||
# Context Compaction Policy
|
||||
"ContextCompactionPolicy",
|
||||
"ContextCompactionPolicyProtocol",
|
||||
@@ -295,6 +296,11 @@ 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
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
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
|
||||
@@ -94,65 +94,6 @@ class SessionDeduplicationCache:
|
||||
self._cache.clear()
|
||||
self._stats = {"duplicates_prevented": 0, "tokens_saved": 0}
|
||||
|
||||
def deduplicate_topics(topics: list, key: str = "title", similarity_threshold: float = 0.8) -> list:
|
||||
"""
|
||||
Programmatic deduplication of topics/items before agent processing.
|
||||
|
||||
This helps prevent duplicate content from being passed to downstream agents,
|
||||
reducing token waste and improving quality.
|
||||
|
||||
Args:
|
||||
topics: List of topic dicts or strings
|
||||
key: Key to use for comparison if topics are dicts (default: "title")
|
||||
similarity_threshold: Similarity threshold for fuzzy matching (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of topics
|
||||
"""
|
||||
if not topics:
|
||||
return topics
|
||||
|
||||
seen_hashes = set()
|
||||
seen_normalized = set()
|
||||
unique_topics = []
|
||||
|
||||
for topic in topics:
|
||||
# Get the content to compare
|
||||
if isinstance(topic, dict):
|
||||
content = str(topic.get(key, topic.get("content", str(topic))))
|
||||
else:
|
||||
content = str(topic)
|
||||
|
||||
# Normalize for comparison
|
||||
normalized = content.lower().strip()
|
||||
# Remove common words for better matching
|
||||
normalized = " ".join(w for w in normalized.split() if len(w) > 3)
|
||||
|
||||
# Check exact hash match
|
||||
content_hash = hashlib.sha256(normalized.encode()).hexdigest()
|
||||
if content_hash in seen_hashes:
|
||||
continue
|
||||
|
||||
# Check fuzzy match using simple word overlap
|
||||
is_duplicate = False
|
||||
for seen in seen_normalized:
|
||||
# Calculate Jaccard similarity
|
||||
words1 = set(normalized.split())
|
||||
words2 = set(seen.split())
|
||||
if words1 and words2:
|
||||
intersection = len(words1 & words2)
|
||||
union = len(words1 | words2)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
if similarity >= similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
seen_hashes.add(content_hash)
|
||||
seen_normalized.add(normalized)
|
||||
unique_topics.append(topic)
|
||||
|
||||
return unique_topics
|
||||
|
||||
class EstimationMode(str, Enum):
|
||||
"""Token estimation modes."""
|
||||
@@ -785,7 +726,7 @@ class ContextManager:
|
||||
if protected and tool_name not in self.config.protected_tools:
|
||||
self.config.protected_tools.append(tool_name)
|
||||
|
||||
def truncate_tool_output(self, tool_name: str, output: str) -> str:
|
||||
def truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str = None, run_id: str = None) -> str:
|
||||
"""Truncate tool output according to its budget."""
|
||||
max_tokens = self.get_tool_budget(tool_name)
|
||||
|
||||
@@ -805,6 +746,17 @@ class ContextManager:
|
||||
tail = output[-tail_size:] if tail_size > 0 else ""
|
||||
truncated = f"{head}\n...[{len(output):,} chars, showing first/last portions]...\n{tail}"
|
||||
|
||||
# Store full output for later retrieval
|
||||
try:
|
||||
from ..runtime.tool_output_store import get_tool_output_store
|
||||
store = get_tool_output_store(run_id)
|
||||
metadata = store.store(tool_name, output, call_id=tool_call_id)
|
||||
if metadata:
|
||||
truncated = store.format_reference(metadata, truncated)
|
||||
logger.debug(f"Stored full {tool_name} output ({len(output)} bytes) at {metadata.get('path')}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to store tool output: {e}")
|
||||
|
||||
self._add_history_event(
|
||||
OptimizationEventType.CAP_OUTPUTS,
|
||||
tokens_before=current_tokens,
|
||||
|
||||
@@ -18,12 +18,38 @@ from .protocols import (
|
||||
GatewayEvent,
|
||||
GatewayMessage,
|
||||
EventType,
|
||||
OperatorScope,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
OutboundDeliveryProtocol,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
# Home channel and delivery protocols
|
||||
HomeChannelRegistryProtocol,
|
||||
DeliveryResolverProtocol,
|
||||
# Agent-facing outbound messaging
|
||||
OutboundMessengerProtocol,
|
||||
DeliveryResult,
|
||||
TargetInfo,
|
||||
# Inbound route binding (Issue #2225)
|
||||
RouteBinding,
|
||||
RouteFacts,
|
||||
RouteMatch,
|
||||
resolve_route,
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
SendDecision,
|
||||
SendPolicyProtocol,
|
||||
SendPolicy,
|
||||
# Protocol version negotiation
|
||||
PROTOCOL_VERSION,
|
||||
MIN_PROTOCOL_VERSION,
|
||||
MAX_PROTOCOL_VERSION,
|
||||
ProtocolHello,
|
||||
ProtocolHelloOk,
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
@@ -85,12 +111,38 @@ __all__ = [
|
||||
"GatewayEvent",
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
"OperatorScope",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"OutboundDeliveryProtocol",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Home channel and delivery protocols
|
||||
"HomeChannelRegistryProtocol",
|
||||
"DeliveryResolverProtocol",
|
||||
# Agent-facing outbound messaging
|
||||
"OutboundMessengerProtocol",
|
||||
"DeliveryResult",
|
||||
"TargetInfo",
|
||||
# Inbound route binding (Issue #2225)
|
||||
"RouteBinding",
|
||||
"RouteFacts",
|
||||
"RouteMatch",
|
||||
"resolve_route",
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
"SendDecision",
|
||||
"SendPolicyProtocol",
|
||||
"SendPolicy",
|
||||
# Protocol version negotiation
|
||||
"PROTOCOL_VERSION",
|
||||
"MIN_PROTOCOL_VERSION",
|
||||
"MAX_PROTOCOL_VERSION",
|
||||
"ProtocolHello",
|
||||
"ProtocolHelloOk",
|
||||
"GapInfo",
|
||||
"ResumeSnapshot",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
|
||||
@@ -17,14 +17,33 @@ class SessionConfig:
|
||||
max_messages: Maximum messages to keep in history (0 = unlimited)
|
||||
persist: Whether to persist session state
|
||||
persist_path: Path for session persistence
|
||||
resume_window: How long (seconds) a session stays resumable after disconnect
|
||||
max_inbox: Maximum queued messages per session (0 = unlimited, default 256)
|
||||
metadata: Additional session metadata
|
||||
mirror_runtime_state: Enable runtime state mirroring for native transcript replay (Issue #1943)
|
||||
"""
|
||||
|
||||
timeout: int = 3600 # 1 hour default
|
||||
max_messages: int = 1000
|
||||
persist: bool = False
|
||||
persist_path: Optional[str] = None
|
||||
resume_window: int = 86400 # 24 hours default
|
||||
max_inbox: int = 256 # Default bounded queue size
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
mirror_runtime_state: bool = False # Opt-in to avoid storage bloat
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate configuration values."""
|
||||
if self.max_inbox < 0:
|
||||
raise ValueError(
|
||||
"max_inbox must be >= 0 (use 0 for unlimited queue size)"
|
||||
)
|
||||
if self.timeout < 0:
|
||||
raise ValueError("timeout must be >= 0")
|
||||
if self.max_messages < 0:
|
||||
raise ValueError("max_messages must be >= 0")
|
||||
if self.resume_window < 0:
|
||||
raise ValueError("resume_window must be >= 0")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -33,7 +52,10 @@ class SessionConfig:
|
||||
"max_messages": self.max_messages,
|
||||
"persist": self.persist,
|
||||
"persist_path": self.persist_path,
|
||||
"resume_window": self.resume_window,
|
||||
"max_inbox": self.max_inbox,
|
||||
"metadata": self.metadata,
|
||||
"mirror_runtime_state": self.mirror_runtime_state,
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +228,13 @@ class GatewayConfig:
|
||||
reconnect_timeout: Time to wait for reconnection before closing session
|
||||
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
|
||||
ssl_key: Path to SSL key
|
||||
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
|
||||
push: Push notification service configuration
|
||||
auth_scopes: Optional operator scope policy mapping token -> list of
|
||||
scope names (see OperatorScope). When None/empty (default), any
|
||||
successfully authenticated client is granted all scopes — identical
|
||||
to the previous binary behaviour. Single-operator setups are
|
||||
unaffected.
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
@@ -222,13 +250,76 @@ class GatewayConfig:
|
||||
reconnect_timeout: int = 60
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
max_buffered_bytes: int = 1024 * 1024 # 1MB default
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified."""
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
if self.bind_host is None:
|
||||
self.bind_host = self.host
|
||||
|
||||
if self.max_buffered_bytes < 0:
|
||||
raise ValueError(
|
||||
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
|
||||
)
|
||||
if self.max_connections < 0:
|
||||
raise ValueError("max_connections must be >= 0")
|
||||
if self.heartbeat_interval < 0:
|
||||
raise ValueError("heartbeat_interval must be >= 0")
|
||||
if self.reconnect_timeout < 0:
|
||||
raise ValueError("reconnect_timeout must be >= 0")
|
||||
|
||||
@property
|
||||
def has_scope_policy(self) -> bool:
|
||||
"""Whether an operator scope policy is configured.
|
||||
|
||||
When False (the default), every authenticated client is granted all
|
||||
scopes — preserving the original binary auth behaviour.
|
||||
"""
|
||||
return bool(self.auth_scopes)
|
||||
|
||||
def resolve_scopes(self, token: Optional[str]) -> List[str]:
|
||||
"""Resolve the operator scopes granted to ``token``.
|
||||
|
||||
Backward-compatible contract:
|
||||
* No scope policy configured -> all scopes (today's behaviour).
|
||||
* Policy configured + token listed -> that token's scopes.
|
||||
* Policy configured + token absent/None -> no scopes (deny).
|
||||
|
||||
Scope names are returned as plain strings so callers in the wrapper
|
||||
can compare against ``OperatorScope`` values without importing them.
|
||||
Unknown scope names (e.g. typos in ``gateway.yaml``) are dropped and a
|
||||
warning is logged so misconfiguration surfaces early instead of
|
||||
silently denying all access.
|
||||
"""
|
||||
from .protocols import OperatorScope
|
||||
|
||||
valid_scopes = {s.value for s in OperatorScope.all()}
|
||||
all_scopes = list(valid_scopes)
|
||||
if not self.auth_scopes:
|
||||
return all_scopes
|
||||
if token is None:
|
||||
return []
|
||||
granted = self.auth_scopes.get(token)
|
||||
if granted is None:
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
unknown: List[str] = []
|
||||
for s in granted:
|
||||
name = str(s)
|
||||
if name in valid_scopes:
|
||||
resolved.append(name)
|
||||
else:
|
||||
unknown.append(name)
|
||||
if unknown:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Ignoring unknown operator scope(s) %s; valid scopes are %s",
|
||||
unknown,
|
||||
sorted(valid_scopes),
|
||||
)
|
||||
return resolved
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
@@ -243,7 +334,9 @@ class GatewayConfig:
|
||||
"heartbeat_interval": self.heartbeat_interval,
|
||||
"reconnect_timeout": self.reconnect_timeout,
|
||||
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
|
||||
"max_buffered_bytes": self.max_buffered_bytes,
|
||||
"push": self.push.to_dict(),
|
||||
"scope_policy_enabled": self.has_scope_policy,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -365,6 +458,45 @@ class MultiChannelGatewayConfig:
|
||||
"""
|
||||
# Parse gateway section
|
||||
gw_data = data.get("gateway", {})
|
||||
|
||||
# Parse session config if provided
|
||||
session_config = SessionConfig()
|
||||
if "session_config" in gw_data:
|
||||
sc_data = gw_data["session_config"]
|
||||
if isinstance(sc_data, dict):
|
||||
session_config = SessionConfig(
|
||||
timeout=sc_data.get("timeout", 3600),
|
||||
max_messages=sc_data.get("max_messages", 1000),
|
||||
persist=sc_data.get("persist", False),
|
||||
persist_path=sc_data.get("persist_path"),
|
||||
resume_window=sc_data.get("resume_window", 86400),
|
||||
max_inbox=sc_data.get("max_inbox", 256),
|
||||
metadata=sc_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# Parse optional operator scope policy. Two supported shapes:
|
||||
# gateway:
|
||||
# auth:
|
||||
# tokens:
|
||||
# - token: "${VIEWER_TOKEN}"
|
||||
# scopes: [read]
|
||||
# or a flat mapping:
|
||||
# gateway:
|
||||
# auth_scopes:
|
||||
# "${VIEWER_TOKEN}": [read]
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, dict) and isinstance(auth_section.get("tokens"), list):
|
||||
auth_scopes = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, dict) and entry.get("token"):
|
||||
auth_scopes[str(entry["token"])] = list(entry.get("scopes", []))
|
||||
elif isinstance(gw_data.get("auth_scopes"), dict):
|
||||
auth_scopes = {
|
||||
str(tok): list(scopes)
|
||||
for tok, scopes in gw_data["auth_scopes"].items()
|
||||
}
|
||||
|
||||
gateway_config = GatewayConfig(
|
||||
host=gw_data.get("host", "127.0.0.1"),
|
||||
port=gw_data.get("port", 8765),
|
||||
@@ -372,6 +504,14 @@ class MultiChannelGatewayConfig:
|
||||
allowed_origins=gw_data.get("allowed_origins", []),
|
||||
auth_token=gw_data.get("auth_token"),
|
||||
max_connections=gw_data.get("max_connections", 1000),
|
||||
max_sessions_per_agent=gw_data.get("max_sessions_per_agent", 0),
|
||||
session_config=session_config,
|
||||
heartbeat_interval=gw_data.get("heartbeat_interval", 30),
|
||||
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
|
||||
ssl_cert=gw_data.get("ssl_cert"),
|
||||
ssl_key=gw_data.get("ssl_key"),
|
||||
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
|
||||
auth_scopes=auth_scopes,
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -17,19 +17,62 @@ from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
# Gateway protocol versioning constants
|
||||
GATEWAY_PROTOCOL_VERSION = 1
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 1
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
from ..bots.presentation import MessagePresentation
|
||||
from ..scheduler.models import DeliveryTarget
|
||||
|
||||
|
||||
class ConnectErrorCode(str, Enum):
|
||||
"""Structured error codes for connection failures."""
|
||||
AUTH_REQUIRED = "auth_required"
|
||||
AUTH_UNAUTHORIZED = "auth_unauthorized"
|
||||
PROTOCOL_UNSUPPORTED = "protocol_unsupported"
|
||||
PAIRING_REQUIRED = "pairing_required"
|
||||
AGENT_NOT_FOUND = "agent_not_found"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
ORIGIN_NOT_ALLOWED = "origin_not_allowed"
|
||||
CONFIGURATION_ERROR = "configuration_error"
|
||||
|
||||
|
||||
class ConnectRecoveryStep(str, Enum):
|
||||
"""Machine-readable recovery hint for a connection rejection.
|
||||
|
||||
Clients branch on ``(code, next_step)`` to implement deterministic,
|
||||
uniform reconnect behaviour without parsing free-text reasons:
|
||||
|
||||
REAUTHENTICATE: Obtain fresh credentials, then reconnect.
|
||||
REPAIR: Re-run the device pairing flow, then reconnect.
|
||||
UPGRADE_CLIENT: The client protocol is too old; update the client.
|
||||
DOWNGRADE_CLIENT: The client protocol is newer than the server
|
||||
supports; use an older client or wait for a server upgrade.
|
||||
WAIT_THEN_RETRY: Back off (see ``retry_after_seconds``) then reconnect.
|
||||
DO_NOT_RETRY: The rejection is terminal; reconnecting will not help.
|
||||
"""
|
||||
|
||||
REAUTHENTICATE = "reauthenticate"
|
||||
REPAIR = "repair"
|
||||
UPGRADE_CLIENT = "upgrade_client"
|
||||
DOWNGRADE_CLIENT = "downgrade_client"
|
||||
WAIT_THEN_RETRY = "wait_then_retry"
|
||||
DO_NOT_RETRY = "do_not_retry"
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
@@ -53,6 +96,7 @@ class EventType(str, Enum):
|
||||
# Message events
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ACK = "message_ack"
|
||||
MESSAGE_ABORT = "message_abort"
|
||||
TYPING = "typing"
|
||||
|
||||
# Streaming events (relayed from agent's StreamEventEmitter)
|
||||
@@ -84,6 +128,132 @@ class EventType(str, Enum):
|
||||
# Polling events
|
||||
POLL_REQUEST = "poll_request"
|
||||
POLL_RESPONSE = "poll_response"
|
||||
|
||||
# Handshake events
|
||||
HELLO = "hello"
|
||||
HELLO_OK = "hello_ok"
|
||||
HELLO_ERROR = "hello_error"
|
||||
|
||||
|
||||
class OperatorScope(str, Enum):
|
||||
"""Operator authorisation scopes for multi-operator Gateway access control.
|
||||
|
||||
These describe *what an authenticated operator is allowed to do*, layered on
|
||||
top of (and orthogonal to) authentication. The vocabulary lives in the core
|
||||
SDK so that protocol clients and the wrapper Gateway share the same names;
|
||||
enforcement happens in the wrapper where requests are dispatched.
|
||||
|
||||
Scopes:
|
||||
READ: View dashboard / receive session transcripts and status.
|
||||
WRITE: Send messages as the agent.
|
||||
APPROVALS: Resolve tool-execution approvals (security-sensitive).
|
||||
PAIRING: Approve / revoke device pairing.
|
||||
ADMIN: Channel control (pause / resume / reconnect) and management.
|
||||
"""
|
||||
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
APPROVALS = "approvals"
|
||||
PAIRING = "pairing"
|
||||
ADMIN = "admin"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> "List[OperatorScope]":
|
||||
"""Return every scope (granted by default when no policy is configured)."""
|
||||
return list(cls)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelloParams:
|
||||
"""Parameters for initiating a versioned handshake.
|
||||
|
||||
Attributes:
|
||||
agent_id: The agent to connect to
|
||||
protocol_min: Minimum protocol version the client supports
|
||||
protocol_max: Maximum protocol version the client supports
|
||||
capabilities: Optional list of capability tokens the client supports
|
||||
session_id: Optional session to resume
|
||||
since: Optional cursor for event replay
|
||||
"""
|
||||
agent_id: str
|
||||
protocol_min: int
|
||||
protocol_max: int
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
session_id: Optional[str] = None
|
||||
since: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelloResult:
|
||||
"""Result of a successful handshake negotiation.
|
||||
|
||||
Attributes:
|
||||
protocol: The negotiated protocol version
|
||||
features: Supported methods and events
|
||||
policy: Gateway policy limits (max_payload, heartbeat_ms, etc.)
|
||||
session_id: The session ID for this connection
|
||||
resumed: Whether an existing session was resumed
|
||||
cursor: Current event cursor position
|
||||
"""
|
||||
protocol: int
|
||||
features: Dict[str, List[str]] # {"methods": [...], "events": [...]}
|
||||
policy: Dict[str, int] # {"max_payload": ..., "heartbeat_ms": ...}
|
||||
session_id: str
|
||||
resumed: bool
|
||||
cursor: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelloError:
|
||||
"""Structured connect-rejection envelope.
|
||||
|
||||
Emitted from *every* connection rejection path — both pre-handshake
|
||||
transport checks (auth/origin/rate-limit) and handshake negotiation —
|
||||
so clients can implement deterministic reconnect logic by branching on
|
||||
``(code, next_step)`` instead of string-matching close reasons.
|
||||
|
||||
Attributes:
|
||||
code: Structured, machine-readable error code.
|
||||
message: Human-readable error message (display only).
|
||||
next_step: Machine-readable recovery hint telling the client what to
|
||||
do next (re-authenticate, re-pair, upgrade, wait then retry, ...).
|
||||
retry_after_seconds: Optional backoff hint (rate limiting / transient
|
||||
unavailability). Only meaningful with ``WAIT_THEN_RETRY``.
|
||||
next_action: Deprecated free-text hint, retained for backward
|
||||
compatibility. Prefer ``next_step``.
|
||||
"""
|
||||
code: ConnectErrorCode
|
||||
message: str
|
||||
next_step: Optional[ConnectRecoveryStep] = None
|
||||
retry_after_seconds: Optional[int] = None
|
||||
next_action: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a ``hello_error`` wire frame.
|
||||
|
||||
The ``next`` key is preserved for backward compatibility with existing
|
||||
clients; ``next_step`` and ``retry_after_seconds`` are the structured
|
||||
recovery fields new clients should branch on.
|
||||
"""
|
||||
frame: Dict[str, Any] = {
|
||||
"type": "hello_error",
|
||||
"code": self.code.value,
|
||||
"message": self.message,
|
||||
}
|
||||
if self.next_step is not None:
|
||||
frame["next_step"] = self.next_step.value
|
||||
if self.retry_after_seconds is not None:
|
||||
frame["retry_after_seconds"] = self.retry_after_seconds
|
||||
# Backward-compatible legacy field: fall back to next_step's value.
|
||||
# Omitted entirely when there is no recovery hint so clients using
|
||||
# strict schema validation or ``if frame["next"]`` are not tripped by
|
||||
# an explicit ``null``.
|
||||
legacy_next = self.next_action
|
||||
if legacy_next is None and self.next_step is not None:
|
||||
legacy_next = self.next_step.value
|
||||
if legacy_next is not None:
|
||||
frame["next"] = legacy_next
|
||||
return frame
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -97,6 +267,20 @@ class GatewayEvent:
|
||||
timestamp: Event creation time
|
||||
source: Source identifier (agent_id, client_id, etc.)
|
||||
target: Target identifier (optional, for directed events)
|
||||
sequence: Monotonic sequence number for gap detection (optional)
|
||||
|
||||
Wire Protocol Extensions:
|
||||
When events are sent over the gateway, additional fields are added:
|
||||
- seq: Top-level monotonic sequence number for gap detection
|
||||
- cursor: Event cursor position (also stored in data['cursor'])
|
||||
|
||||
Resume Protocol:
|
||||
The 'joined' acknowledgment includes:
|
||||
- cursor: Current head cursor position
|
||||
- oldest_cursor: Oldest event still in buffer
|
||||
- resync_required: True if requested 'since' is below oldest_cursor
|
||||
|
||||
When resync_required=true, a 'snapshot' message follows with full state.
|
||||
"""
|
||||
|
||||
type: Union[EventType, str]
|
||||
@@ -105,10 +289,11 @@ class GatewayEvent:
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
source: Optional[str] = None
|
||||
target: Optional[str] = None
|
||||
sequence: Optional[int] = None # Monotonic sequence for gap detection
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
result = {
|
||||
"type": self.type.value if isinstance(self.type, EventType) else self.type,
|
||||
"data": self.data,
|
||||
"event_id": self.event_id,
|
||||
@@ -116,6 +301,9 @@ class GatewayEvent:
|
||||
"source": self.source,
|
||||
"target": self.target,
|
||||
}
|
||||
if self.sequence is not None:
|
||||
result["sequence"] = self.sequence
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "GatewayEvent":
|
||||
@@ -133,6 +321,7 @@ class GatewayEvent:
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
source=data.get("source"),
|
||||
target=data.get("target"),
|
||||
sequence=data.get("sequence"),
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +337,7 @@ class GatewayMessage:
|
||||
timestamp: Message creation time
|
||||
metadata: Additional message metadata
|
||||
reply_to: ID of message being replied to (optional)
|
||||
presentation: Optional interactive presentation (buttons, menus, etc.)
|
||||
"""
|
||||
|
||||
content: Union[str, Dict[str, Any]]
|
||||
@@ -157,10 +347,11 @@ class GatewayMessage:
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
reply_to: Optional[str] = None
|
||||
presentation: Optional["MessagePresentation"] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
data = {
|
||||
"content": self.content,
|
||||
"sender_id": self.sender_id,
|
||||
"session_id": self.session_id,
|
||||
@@ -169,10 +360,20 @@ class GatewayMessage:
|
||||
"metadata": self.metadata,
|
||||
"reply_to": self.reply_to,
|
||||
}
|
||||
if self.presentation is not None:
|
||||
data["presentation"] = self.presentation.to_dict()
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "GatewayMessage":
|
||||
"""Create from dictionary."""
|
||||
from ..bots.presentation import MessagePresentation
|
||||
|
||||
presentation = None
|
||||
raw_presentation = data.get("presentation")
|
||||
if isinstance(raw_presentation, dict):
|
||||
presentation = MessagePresentation.from_dict(raw_presentation)
|
||||
|
||||
return cls(
|
||||
content=data.get("content", ""),
|
||||
sender_id=data.get("sender_id", "unknown"),
|
||||
@@ -181,6 +382,7 @@ class GatewayMessage:
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
reply_to=data.get("reply_to"),
|
||||
presentation=presentation,
|
||||
)
|
||||
|
||||
|
||||
@@ -688,6 +890,301 @@ class DeliveryGuaranteeProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OutboundDeliveryProtocol(Protocol):
|
||||
"""Protocol for durable outbound message delivery.
|
||||
|
||||
Ensures messages sent to external channels (Telegram, Slack, Discord, etc.)
|
||||
are persisted before sending and can be retried on failure. This provides
|
||||
crash-safe at-least-once delivery for channel replies.
|
||||
|
||||
Example usage (implementation in praisonai wrapper):
|
||||
from praisonai.bots import OutboundQueue
|
||||
|
||||
outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")
|
||||
|
||||
# Enqueue before sending
|
||||
key = await outbox.enqueue(
|
||||
idempotency_key="msg-123",
|
||||
target_channel="telegram:12345",
|
||||
payload={"text": "Hello", "metadata": {...}}
|
||||
)
|
||||
|
||||
# Attempt delivery
|
||||
success = await deliver_with_retry(adapter, channel_id, payload)
|
||||
|
||||
# Mark as sent only if successful
|
||||
if success:
|
||||
await outbox.mark_sent(key)
|
||||
|
||||
# On restart, drain pending messages
|
||||
await outbox.drain(delivery_handler)
|
||||
"""
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
idempotency_key: str,
|
||||
target: str,
|
||||
payload: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Persist an outbound message for delivery.
|
||||
|
||||
Args:
|
||||
idempotency_key: Unique key to prevent duplicate sends
|
||||
target: Target channel identifier (e.g., "telegram:12345")
|
||||
payload: Message payload to deliver
|
||||
metadata: Optional metadata for tracking/routing
|
||||
|
||||
Returns:
|
||||
Unique entry key for tracking this message
|
||||
"""
|
||||
...
|
||||
|
||||
async def mark_sent(self, key: str) -> bool:
|
||||
"""Mark a message as successfully sent.
|
||||
|
||||
Args:
|
||||
key: The entry key returned by enqueue()
|
||||
|
||||
Returns:
|
||||
True if marked successfully, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
async def mark_failed(
|
||||
self,
|
||||
key: str,
|
||||
error: str,
|
||||
permanent: bool = False,
|
||||
) -> bool:
|
||||
"""Mark a message as failed.
|
||||
|
||||
Args:
|
||||
key: The entry key returned by enqueue()
|
||||
error: Error description
|
||||
permanent: If True, won't retry this message
|
||||
|
||||
Returns:
|
||||
True if marked successfully, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
async def drain(
|
||||
self,
|
||||
sender: Callable[[str, Dict[str, Any]], Awaitable[bool]],
|
||||
limit: Optional[int] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Process pending messages.
|
||||
|
||||
Called on startup to retry unsent messages. Messages are processed
|
||||
oldest-first to maintain order.
|
||||
|
||||
Args:
|
||||
sender: Async function that attempts delivery. Should return
|
||||
True on success, False to retry later.
|
||||
limit: Optional max messages to process
|
||||
|
||||
Returns:
|
||||
Tuple of (succeeded, failed) counts
|
||||
"""
|
||||
...
|
||||
|
||||
def pending_count(self) -> int:
|
||||
"""Get count of pending messages awaiting delivery."""
|
||||
...
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get total number of messages in queue."""
|
||||
...
|
||||
|
||||
async def purge_old(self, max_age_seconds: int = 86400 * 7) -> int:
|
||||
"""Remove old sent messages.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Age threshold for removal
|
||||
|
||||
Returns:
|
||||
Number of messages purged
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound route binding (Issue #2225)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class RouteBinding:
|
||||
"""A single declarative inbound-routing rule.
|
||||
|
||||
A binding maps a set of optional inbound conditions to a handling agent.
|
||||
Bindings are evaluated most-specific-first so operators get deterministic,
|
||||
debuggable routing across a fleet of agents behind one gateway.
|
||||
|
||||
All condition fields are optional; ``None`` means "do not constrain on this
|
||||
field". A binding matches a set of :class:`RouteFacts` only when *every*
|
||||
non-``None`` condition equals the corresponding fact.
|
||||
|
||||
Attributes:
|
||||
agent: The agent id to route to when this binding matches.
|
||||
chat_type: Chat type ("dm" | "group" | "channel").
|
||||
peer: Sender/user id (most specific).
|
||||
role: Role / guild-role membership of the sender.
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
chat_type: Optional[str] = None
|
||||
peer: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
priority: int = 0
|
||||
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
_SPECIFICITY = {
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
"""Sum of weights for the conditions this binding constrains on."""
|
||||
score = 0
|
||||
for field_name, weight in self._SPECIFICITY.items():
|
||||
if getattr(self, field_name) is not None:
|
||||
score += weight
|
||||
return score
|
||||
|
||||
def matches(self, facts: "RouteFacts") -> bool:
|
||||
"""Return True if every constrained condition equals the facts."""
|
||||
if self.peer is not None and str(self.peer) != str(facts.peer):
|
||||
return False
|
||||
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
if self.chat_type is not None and self.chat_type != facts.chat_type:
|
||||
return False
|
||||
if self.role is not None:
|
||||
expected_role = str(self.role)
|
||||
if expected_role not in [str(role) for role in (facts.roles or [])]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding":
|
||||
"""Create a binding from a YAML/dict mapping.
|
||||
|
||||
Accepts ``agent`` (required). Unknown keys are ignored so the shape
|
||||
can evolve without breaking older configs.
|
||||
"""
|
||||
return cls(
|
||||
agent=data.get("agent", "default"),
|
||||
chat_type=data.get("chat_type"),
|
||||
peer=_as_opt_str(data.get("peer")),
|
||||
role=data.get("role"),
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteFacts:
|
||||
"""Inbound facts extracted from a message, used to resolve a binding.
|
||||
|
||||
Attributes:
|
||||
chat_type: Normalised chat type ("dm" | "group" | "channel" | "default").
|
||||
peer: Sender/user id.
|
||||
roles: Roles/guild-role memberships of the sender.
|
||||
channel_id: The chat/channel id the message arrived in.
|
||||
account: The receiving bot account (multi-account channels).
|
||||
"""
|
||||
|
||||
chat_type: str = "default"
|
||||
peer: Optional[str] = None
|
||||
roles: List[str] = field(default_factory=list)
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteMatch:
|
||||
"""Result of resolving a route.
|
||||
|
||||
Attributes:
|
||||
agent: The resolved agent id.
|
||||
binding: The binding that matched, or ``None`` when the fallback was used.
|
||||
reason: Short human-readable explanation for logging/debugging.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
binding: Optional[RouteBinding] = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _as_opt_str(value: Any) -> Optional[str]:
|
||||
"""Coerce a value to a string, preserving ``None``."""
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def resolve_route(
|
||||
bindings: List[RouteBinding],
|
||||
facts: RouteFacts,
|
||||
default_agent: str = "default",
|
||||
) -> RouteMatch:
|
||||
"""Resolve the handling agent from priority-ordered bindings.
|
||||
|
||||
Bindings are evaluated most-specific-first: the matching binding with the
|
||||
highest ``priority`` wins; ties are broken by specificity (exact peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
facts: Inbound facts extracted from the message.
|
||||
default_agent: Agent id to fall back to when nothing matches.
|
||||
|
||||
Returns:
|
||||
A :class:`RouteMatch` with the selected agent and matched binding.
|
||||
"""
|
||||
best: Optional[RouteBinding] = None
|
||||
best_key: tuple = ()
|
||||
for idx, binding in enumerate(bindings):
|
||||
if not binding.matches(facts):
|
||||
continue
|
||||
# Higher priority wins, then higher specificity, then earlier order.
|
||||
key = (binding.priority, binding.specificity, -idx)
|
||||
if best is None or key > best_key:
|
||||
best = binding
|
||||
best_key = key
|
||||
|
||||
if best is not None:
|
||||
return RouteMatch(
|
||||
agent=best.agent,
|
||||
binding=best,
|
||||
reason=(
|
||||
f"matched binding (priority={best.priority}, "
|
||||
f"specificity={best.specificity})"
|
||||
),
|
||||
)
|
||||
|
||||
return RouteMatch(
|
||||
agent=default_agent,
|
||||
binding=None,
|
||||
reason="no binding matched; using default",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Mode protocols and helpers (bind-aware authentication posture)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -904,3 +1401,343 @@ class SessionBindingProtocol(Protocol):
|
||||
Principal information if found, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Home Channel and Delivery Routing Protocols
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class HomeChannelRegistryProtocol(Protocol):
|
||||
"""Protocol for managing default delivery targets per platform.
|
||||
|
||||
Home channels provide a per-platform default delivery target that can be
|
||||
set once from inside a chat and persisted, so scheduled jobs can deliver
|
||||
results without requiring explicit channel IDs.
|
||||
"""
|
||||
|
||||
def set_home(
|
||||
self,
|
||||
platform: str,
|
||||
chat_id: str,
|
||||
thread_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""Set the home channel for a platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name (e.g., "telegram", "slack", "discord")
|
||||
chat_id: Platform-specific chat/channel ID
|
||||
thread_id: Optional thread ID for threaded platforms
|
||||
"""
|
||||
...
|
||||
|
||||
def get_home(self, platform: str) -> Optional[tuple[str, Optional[str]]]:
|
||||
"""Get the home channel for a platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name to look up
|
||||
|
||||
Returns:
|
||||
Tuple of (chat_id, thread_id) if set, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def platforms_with_home(self) -> List[str]:
|
||||
"""List all platforms that have a home channel configured.
|
||||
|
||||
Returns:
|
||||
List of platform names with home channels
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeliveryResolverProtocol(Protocol):
|
||||
"""Protocol for resolving delivery routing tokens.
|
||||
|
||||
Resolves tokens like "origin", "telegram", "all" to concrete delivery
|
||||
targets at fire time, enabling ergonomic routing without hard-coded IDs.
|
||||
"""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
origin: Optional["DeliveryTarget"] = None
|
||||
) -> List["DeliveryTarget"]:
|
||||
"""Resolve a routing token to concrete delivery targets.
|
||||
|
||||
Token formats:
|
||||
- "origin": Reply to the chat where the job was created (requires origin)
|
||||
- "<platform>": That platform's home channel
|
||||
- "<platform>:<chat_id>[:<thread_id>]": Explicit target
|
||||
- "all": Fan-out to every connected platform with a home channel
|
||||
|
||||
Args:
|
||||
token: Routing token to resolve
|
||||
origin: Original delivery target (for "origin" token)
|
||||
|
||||
Returns:
|
||||
List of concrete delivery targets
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-facing outbound messaging (Issue #2183)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class DeliveryResult:
|
||||
"""Outcome of an agent-initiated proactive send.
|
||||
|
||||
Attributes:
|
||||
ok: Whether the send was accepted for delivery.
|
||||
target: The resolved target the message was routed to.
|
||||
summary: Human-readable summary suitable for returning to the model.
|
||||
detail: Optional extra information (error text, message id, etc.).
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
target: str = ""
|
||||
summary: str = ""
|
||||
detail: Optional[str] = None
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"target": self.target,
|
||||
"summary": self.summary,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TargetInfo:
|
||||
"""A reachable delivery target the agent can address.
|
||||
|
||||
Attributes:
|
||||
target: The token to pass to ``send`` (e.g. "origin", "slack:#ops").
|
||||
platform: Platform name (e.g. "telegram", "slack").
|
||||
kind: Target kind ("origin", "home", or "alias").
|
||||
label: Friendly label for display to the model/user.
|
||||
"""
|
||||
|
||||
target: str
|
||||
platform: str = ""
|
||||
kind: str = "alias"
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
return {
|
||||
"target": self.target,
|
||||
"platform": self.platform,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OutboundMessengerProtocol(Protocol):
|
||||
"""Protocol for agent-facing proactive message delivery.
|
||||
|
||||
A concrete implementation is provided by the running gateway/bot (in the
|
||||
praisonai wrapper) and registered into the per-turn context so the
|
||||
built-in ``send_message`` tool can resolve it. It bridges to the existing
|
||||
delivery stack (DeliveryRouter, HomeChannelRegistry, outbox, mirroring).
|
||||
|
||||
Example usage (implementation in praisonai wrapper)::
|
||||
|
||||
messenger = BotOutboundMessenger(bot, resolver, router)
|
||||
token = register_outbound_messenger(messenger)
|
||||
try:
|
||||
... # agent runs; send_message tool resolves the messenger
|
||||
finally:
|
||||
clear_outbound_messenger(token)
|
||||
"""
|
||||
|
||||
async def send(
|
||||
self,
|
||||
target: str,
|
||||
text: str,
|
||||
*,
|
||||
media: Optional[List[str]] = None,
|
||||
) -> "DeliveryResult":
|
||||
"""Deliver a message to a symbolic target.
|
||||
|
||||
Args:
|
||||
target: Symbolic target token ("origin", "<platform>",
|
||||
"<platform>:<chat_id>[:<thread_id>]", or a friendly alias).
|
||||
text: The message text to send.
|
||||
media: Optional list of local file paths to attach.
|
||||
|
||||
Returns:
|
||||
A :class:`DeliveryResult` describing the outcome.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_targets(self) -> List["TargetInfo"]:
|
||||
"""List the targets currently reachable from this runtime."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
# ``send_message`` lets the model choose where to deliver. Because the target
|
||||
# is model-controlled, poisoned inbound content (prompt injection) can steer an
|
||||
# agent into delivering to a channel the operator never intended. The router
|
||||
# only fails on *unresolvable* targets — a reachability check, not an
|
||||
# authorisation one. This policy seam sits in core, *before* dispatch, so every
|
||||
# messenger implementation is constrained, not just one adapter. Absent a
|
||||
# policy, today's behaviour is preserved (allow-all).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SendDecision:
|
||||
"""Closed decision shape for an outbound send-policy evaluation.
|
||||
|
||||
Attributes:
|
||||
allow: Whether the send to the requested target is permitted.
|
||||
reason: Optional model-readable explanation (used in the
|
||||
:class:`DeliveryResult` detail when denied).
|
||||
"""
|
||||
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SendPolicyProtocol(Protocol):
|
||||
"""Protocol for authorising agent-initiated proactive sends.
|
||||
|
||||
Implementations decide whether an agent may deliver to a model-chosen
|
||||
``target``. The hook is evaluated inside the ``send_message`` path before
|
||||
the messenger dispatches, so a denied send returns a clean, model-readable
|
||||
:class:`DeliveryResult` (``ok=False``) rather than delivering.
|
||||
|
||||
A concrete, config-driven implementation (:class:`SendPolicy`) is provided
|
||||
for the common allow/deny case; richer back-ends may live in plugins.
|
||||
"""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
"""Return a :class:`SendDecision` for the requested ``target``."""
|
||||
...
|
||||
|
||||
|
||||
class SendPolicy:
|
||||
"""A lightweight allow/deny send-policy with an optional default-deny posture.
|
||||
|
||||
This is the config-driven default referenced by ``send_policy`` blocks in
|
||||
``gateway.yaml`` and the ``Bot(..., send_policy=...)`` Python surface. It is
|
||||
intentionally minimal (no heavy dependencies) and lives in core so the
|
||||
built-in ``send_message`` path is always interceptable.
|
||||
|
||||
Matching is exact against the symbolic target token (e.g. ``"origin"``,
|
||||
``"slack:#ops"``, or a friendly alias). With ``default="deny"`` only listed
|
||||
targets are permitted; with ``default="allow"`` all targets are permitted
|
||||
except those in ``deny``.
|
||||
|
||||
Example::
|
||||
|
||||
# default-deny: only the conversation origin and an ops alias allowed
|
||||
SendPolicy(default="deny", allow=["origin", "ops-alerts"])
|
||||
# default-allow: everything permitted except an exec channel
|
||||
SendPolicy(default="allow", deny=["slack:#exec"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: str = "allow",
|
||||
allow: Optional[List[str]] = None,
|
||||
deny: Optional[List[str]] = None,
|
||||
):
|
||||
default = (default or "allow").lower()
|
||||
if default not in ("allow", "deny"):
|
||||
raise ValueError(
|
||||
f"send_policy default must be 'allow' or 'deny', got {default!r}"
|
||||
)
|
||||
self.default = default
|
||||
self.allow = list(allow or [])
|
||||
self.deny = list(deny or [])
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
if target in self.deny:
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is denied by send_policy",
|
||||
)
|
||||
if self.default == "deny":
|
||||
if target in self.allow:
|
||||
return SendDecision(allow=True)
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is not permitted by send_policy",
|
||||
)
|
||||
return SendDecision(allow=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Version Negotiation (Issue #2130)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Protocol version constants
|
||||
PROTOCOL_VERSION = 1
|
||||
MIN_PROTOCOL_VERSION = 1
|
||||
MAX_PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
class ProtocolHello(TypedDict, total=False):
|
||||
"""Protocol version negotiation handshake request.
|
||||
|
||||
Sent by client during join to negotiate protocol version.
|
||||
"""
|
||||
min_version: int # Minimum protocol version client supports
|
||||
max_version: int # Maximum protocol version client supports
|
||||
features: List[str] # Optional feature flags
|
||||
|
||||
|
||||
class ProtocolHelloOk(TypedDict):
|
||||
"""Protocol version negotiation response.
|
||||
|
||||
Server's response to protocol negotiation.
|
||||
"""
|
||||
protocol_version: int # Negotiated protocol version
|
||||
server_min_version: int # Server's minimum supported version
|
||||
server_max_version: int # Server's maximum supported version
|
||||
features: List[str] # Enabled feature flags
|
||||
|
||||
|
||||
class GapInfo(TypedDict):
|
||||
"""Information about a gap in the event sequence."""
|
||||
expected_seq: int # Expected sequence number
|
||||
received_seq: int # Received sequence number
|
||||
missed_count: int # Number of events missed
|
||||
|
||||
|
||||
class ResumeSnapshot(TypedDict, total=False):
|
||||
"""Complete snapshot for session resumption.
|
||||
|
||||
Provides all necessary state for one-round-trip reconnection.
|
||||
"""
|
||||
cursor: int # Resume cursor position
|
||||
sequence: int # Current sequence number for gap detection
|
||||
events: List[Dict[str, Any]] # Replayed events since cursor
|
||||
presence: List[Dict[str, Any]] # Current presence information
|
||||
health: Dict[str, Any] # Gateway health status
|
||||
session_state: Dict[str, Any] # Session-specific state
|
||||
Loaded 100 of 526 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user