mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 17:31:52 -10:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7f458b35 | ||
|
|
83f7babd89 | ||
|
|
0d65b5aaf9 | ||
|
|
22e1e0bdb0 | ||
|
|
cc5683c5c4 | ||
|
|
d4e82d3909 | ||
|
|
35146cd1c8 | ||
|
|
3ad181950c | ||
|
|
09970cd348 | ||
|
|
387b81d324 | ||
|
|
ae1cf98fc9 | ||
|
|
52354bf3f4 | ||
|
|
5bd317178a | ||
|
|
94bc2ce611 | ||
|
|
55287d4cc3 | ||
|
|
a8b72b046f | ||
|
|
69fce71ad5 | ||
|
|
e4e2657f17 | ||
|
|
56443bf3f8 | ||
|
|
258ab1cd20 | ||
|
|
63248a3a46 | ||
|
|
331b47013e | ||
|
|
162c10c16f | ||
|
|
cdefa98c09 | ||
|
|
5b2d924121 | ||
|
|
9fcf1b4acd | ||
|
|
7bec5453d0 | ||
|
|
7c87046753 | ||
|
|
81ea4d7706 | ||
|
|
f1d53a409b | ||
|
|
384bb06210 | ||
|
|
2dd1a80fde | ||
|
|
d4a1b6e64b | ||
|
|
09e85a24d2 | ||
|
|
5990367b3d | ||
|
|
01a20e307c | ||
|
|
ed3a689f8c | ||
|
|
7313b64544 | ||
|
|
dacb1dc42c | ||
|
|
74241c1459 | ||
|
|
b1048da6a5 | ||
|
|
ffc8cb1bbc | ||
|
|
eceaa96141 | ||
|
|
e632aadd42 | ||
|
|
bbcc505693 | ||
|
|
cc0544a4e3 | ||
|
|
b8455205f6 | ||
|
|
a10af4e839 | ||
|
|
ff37cbcf6f | ||
|
|
a42c6d325e | ||
|
|
724e4af963 | ||
|
|
7cc9718c41 | ||
|
|
72e477f4a3 | ||
|
|
ecfd858269 | ||
|
|
92c6690ab2 | ||
|
|
efb92fdd2e | ||
|
|
9c2f14e042 | ||
|
|
8b0567262d | ||
|
|
d80bff2d9a | ||
|
|
3e29936e86 | ||
|
|
34c6423dbc | ||
|
|
6b4e1b3590 | ||
|
|
7eb01489fd | ||
|
|
731e222a53 | ||
|
|
df50822ff6 | ||
|
|
7480c4389a | ||
|
|
494cd8b44a | ||
|
|
2e733e16d7 | ||
|
|
97141c75bd | ||
|
|
c660495911 | ||
|
|
0cb35a4fe3 | ||
|
|
3622b1a3d9 | ||
|
|
20d418e1de |
No files matched your search
@@ -207,21 +207,44 @@ runs:
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance. You have access to repository tools and can help with:
|
||||
- Code analysis and implementation
|
||||
- GitHub workflows and automation
|
||||
- Pull request reviews and feedback
|
||||
- Issue resolution and bug fixes
|
||||
- Documentation updates
|
||||
- Testing and deployment
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
1. ALWAYS create a new git branch appropriately named (e.g. \`feature/issue-<number>\`).
|
||||
2. MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
3. Make your necessary code changes honoring the rules in AGENTS.md.
|
||||
4. Commit your changes and push the branch to the remote repository.
|
||||
5. Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request containing your work.
|
||||
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- 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:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → THEN trigger Copilot
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -14,6 +17,11 @@ on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
@@ -27,8 +35,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -45,14 +57,18 @@ jobs:
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
@@ -62,8 +78,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -79,14 +99,297 @@ jobs:
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
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 comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Chain Claude After Reviewers
|
||||
|
||||
# Event-driven: triggers when Copilot finishes reviewing a PR
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
# Trigger Claude after Copilot finishes on a PR — Claude is the FINAL reviewer
|
||||
# Claude reads ALL previous reviews (Qodo, Coderabbit, Copilot) and fixes any valid issues
|
||||
claude-final-review:
|
||||
if: |
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.user.login, 'copilot')) ||
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.user.login, 'copilot'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Claude final review + fix request
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
})
|
||||
|
||||
# Trigger Claude after Gemini finishes on an Issue or PR
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Claude analysis request (final reviewer)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
})
|
||||
@@ -15,15 +15,18 @@ jobs:
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Run Claude Issue Triage
|
||||
uses: ./.github/actions/claude-issue-triage-action
|
||||
|
||||
+155
-15
@@ -11,10 +11,116 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# Job 1: Lightweight triage for ALL new issues (including external users)
|
||||
# Posts welcome comment + adds triage labels. No Claude code execution.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'opened' &&
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Triage and acknowledge issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const content = title + ' ' + body;
|
||||
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
issue.author_association
|
||||
);
|
||||
|
||||
// --- Label triage ---
|
||||
const labels = [];
|
||||
|
||||
// Kind labels
|
||||
if (content.includes('feature request') || content.includes('[feature')) {
|
||||
labels.push('enhancement');
|
||||
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
|
||||
labels.push('bug');
|
||||
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
|
||||
labels.push('question');
|
||||
}
|
||||
|
||||
// Area labels
|
||||
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
|
||||
labels.push('security');
|
||||
}
|
||||
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
|
||||
labels.push('performance');
|
||||
}
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels
|
||||
});
|
||||
}
|
||||
|
||||
// --- Acknowledgment comment (external users only) ---
|
||||
if (!isOwner) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: [
|
||||
`👋 Thanks for opening this issue, @${issue.user.login}!`,
|
||||
'',
|
||||
'A maintainer will review this shortly. In the meantime:',
|
||||
'- Make sure you\'ve included steps to reproduce (for bugs)',
|
||||
'- Check [existing issues](https://github.com/MervinPraison/PraisonAI/issues) for duplicates',
|
||||
'- Review the [documentation](https://docs.praison.ai) for related guides',
|
||||
'',
|
||||
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
// --- For owner: add 'claude' label to trigger the code-fix job ---
|
||||
if (isOwner) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Job 2: Full Claude code-fix (owner/collaborator only)
|
||||
# Triggers on: labeled 'claude', @claude comments, assignments
|
||||
# ================================================================
|
||||
claude-response:
|
||||
# Allow: human users, github-actions[bot] (auto-comments/labels)
|
||||
# Block: dependabot, cursor, renovate, other bots
|
||||
# Skip 'opened' events — triage job handles those above
|
||||
if: |
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
@@ -33,58 +139,92 @@ jobs:
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
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 }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
allowed_non_write_users: "*"
|
||||
|
||||
direct_prompt: |
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION (MANDATORY before writing code):
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- Does this add heavy implementations to the core SDK (praisonaiagents)? If YES → move it to the wrapper (praisonai) or make it a plugin.
|
||||
- CORE vs WRAPPER vs TOOLS vs DOCS vs PLUGINS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Capabilities Agents actively call during execution (e.g. SurrealDB tool, Slack tool).
|
||||
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
|
||||
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these checks fail, add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
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:
|
||||
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
|
||||
- Create a fix branch and implement a minimal, focused fix
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
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.
|
||||
a) Clone the repository:
|
||||
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
|
||||
b) Copy GitHub authentication from the main repository so push works seamlessly:
|
||||
git -C /tmp/<REPO_NAME> config http."https://github.com/".extraheader "$(git config --get http."https://github.com/".extraheader)"
|
||||
c) Create a feature branch (NEVER commit to main):
|
||||
git -C /tmp/<REPO_NAME> checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
d) Make your changes using absolute paths (e.g. edit /tmp/<REPO_NAME>/docs.json)
|
||||
e) Commit:
|
||||
git -C /tmp/<REPO_NAME> add -A
|
||||
git -C /tmp/<REPO_NAME> commit -m "feat: <description> (fixes #$ISSUE_NUMBER)"
|
||||
f) Push the branch:
|
||||
git -C /tmp/<REPO_NAME> push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
g) Create PR in the EXTERNAL repo:
|
||||
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
|
||||
|
||||
<body>"
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- 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`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
|
||||
- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".
|
||||
NOTE: If you worked on an external repo, the wrapper action will report "No commits" for the main repo at the end. This is expected, do not worry about it.
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
|
||||
@@ -862,6 +862,13 @@ Methods:
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge search <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse config <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse connect <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse langfuse-start <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse status <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse stop <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse test <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse version <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop help <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop loop-main <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp logs <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.112" \
|
||||
"praisonai>=4.5.117" \
|
||||
"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.5.112" \
|
||||
"praisonai>=4.5.117" \
|
||||
"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.5.112" \
|
||||
"praisonai>=4.5.117" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -170,16 +170,17 @@ _LAZY_IMPORTS = {
|
||||
'Handoff': ('praisonaiagents.agent.handoff', 'Handoff'),
|
||||
'handoff': ('praisonaiagents.agent.handoff', 'handoff'),
|
||||
'handoff_filters': ('praisonaiagents.agent.handoff', 'handoff_filters'),
|
||||
'parallel_handoffs': ('praisonaiagents.agent.handoff', 'parallel_handoffs'),
|
||||
'RECOMMENDED_PROMPT_PREFIX': ('praisonaiagents.agent.handoff', 'RECOMMENDED_PROMPT_PREFIX'),
|
||||
'prompt_with_handoff_instructions': ('praisonaiagents.agent.handoff', 'prompt_with_handoff_instructions'),
|
||||
'HandoffConfig': ('praisonaiagents.agent.handoff', 'HandoffConfig'),
|
||||
'HandoffResult': ('praisonaiagents.agent.handoff', 'HandoffResult'),
|
||||
'HandoffInputData': ('praisonaiagents.agent.handoff', 'HandoffInputData'),
|
||||
'ContextPolicy': ('praisonaiagents.agent.handoff', 'ContextPolicy'),
|
||||
'HandoffError': ('praisonaiagents.agent.handoff', 'HandoffError'),
|
||||
'HandoffCycleError': ('praisonaiagents.agent.handoff', 'HandoffCycleError'),
|
||||
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
|
||||
'HandoffError': ('praisonaiagents.errors', 'HandoffError'),
|
||||
'HandoffCycleError': ('praisonaiagents.errors', 'HandoffCycleError'),
|
||||
'HandoffDepthError': ('praisonaiagents.errors', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.errors', 'HandoffTimeoutError'),
|
||||
|
||||
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
|
||||
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
@@ -203,7 +204,15 @@ _LAZY_IMPORTS = {
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
|
||||
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
|
||||
|
||||
# Error hierarchy - structured exception handling
|
||||
'PraisonAIError': ('praisonaiagents.errors', 'PraisonAIError'),
|
||||
'ToolExecutionError': ('praisonaiagents.errors', 'ToolExecutionError'),
|
||||
'LLMError': ('praisonaiagents.errors', 'LLMError'),
|
||||
'ValidationError': ('praisonaiagents.errors', 'ValidationError'),
|
||||
'NetworkError': ('praisonaiagents.errors', 'NetworkError'),
|
||||
'ErrorContextProtocol': ('praisonaiagents.errors', 'ErrorContextProtocol'),
|
||||
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
|
||||
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
|
||||
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
|
||||
|
||||
@@ -110,7 +110,7 @@ def __getattr__(name):
|
||||
|
||||
# Handoff - lightweight
|
||||
_handoff_names = {
|
||||
'Handoff', 'handoff', 'handoff_filters',
|
||||
'Handoff', 'handoff', 'handoff_filters', 'parallel_handoffs',
|
||||
'RECOMMENDED_PROMPT_PREFIX', 'prompt_with_handoff_instructions',
|
||||
'HandoffConfig', 'HandoffResult', 'HandoffInputData',
|
||||
'ContextPolicy', 'HandoffError', 'HandoffCycleError',
|
||||
@@ -214,6 +214,7 @@ __all__ = [
|
||||
'Handoff',
|
||||
'handoff',
|
||||
'handoff_filters',
|
||||
'parallel_handoffs',
|
||||
'RECOMMENDED_PROMPT_PREFIX',
|
||||
'prompt_with_handoff_instructions',
|
||||
'HandoffConfig',
|
||||
|
||||
@@ -176,28 +176,19 @@ _shared_apps = {} # Dict of port -> FastAPI app
|
||||
# Don't import FastAPI dependencies here - use lazy loading instead
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..approval.protocols import ApprovalConfig, ApprovalProtocol
|
||||
from ..config.feature_configs import LearnConfig, MemoryConfig
|
||||
from ..context.models import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
from ..agent.autonomy import AutonomyConfig
|
||||
from ..task.task import Task
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
from ..rag.models import RAGResult, ContextPack
|
||||
from ..eval.results import EvaluationLoopResult
|
||||
|
||||
class BudgetExceededError(Exception):
|
||||
"""Raised when an agent exceeds its max_budget.
|
||||
|
||||
Usage:
|
||||
try:
|
||||
agent.start("...")
|
||||
except BudgetExceededError as e:
|
||||
print(f"Agent '{e.agent_name}' spent ${e.total_cost:.4f} of ${e.max_budget:.4f}")
|
||||
"""
|
||||
def __init__(self, agent_name: str, total_cost: float, max_budget: float):
|
||||
self.agent_name = agent_name
|
||||
self.total_cost = total_cost
|
||||
self.max_budget = max_budget
|
||||
super().__init__(
|
||||
f"Agent '{agent_name}' exceeded budget: "
|
||||
f"${total_cost:.4f} >= ${max_budget:.4f}"
|
||||
)
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
@@ -476,24 +467,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
|
||||
# Each follows: False=disabled, True=defaults, Config=custom
|
||||
# ============================================================
|
||||
memory: Optional[Any] = None, # Union[bool, MemoryConfig, MemoryManager]
|
||||
knowledge: Optional[Union[bool, List[str], Any]] = None, # Union[bool, list, KnowledgeConfig, Knowledge]
|
||||
planning: Optional[Union[bool, Any]] = False, # Union[bool, PlanningConfig]
|
||||
reflection: Optional[Union[bool, Any]] = None, # Union[bool, ReflectionConfig]
|
||||
guardrails: Optional[Union[bool, Callable, Any]] = None, # Union[bool, Callable, GuardrailConfig]
|
||||
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
|
||||
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
|
||||
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
|
||||
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
|
||||
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
|
||||
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
|
||||
guardrails: Optional[Union[bool, str, Callable, 'GuardrailConfig']] = None,
|
||||
web: Optional[Union[bool, str, 'WebConfig']] = None,
|
||||
context: Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']] = None,
|
||||
autonomy: Optional[Union[bool, str, Dict[str, Any], 'AutonomyConfig']] = None,
|
||||
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
|
||||
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
|
||||
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
|
||||
templates: Optional[Any] = None, # TemplateConfig
|
||||
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
|
||||
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
|
||||
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
|
||||
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
|
||||
output: Optional[Union[bool, str, Dict[str, Any], 'OutputConfig']] = None,
|
||||
execution: Optional[Union[bool, str, Dict[str, Any], 'ExecutionConfig']] = None,
|
||||
templates: Optional[Union[Dict[str, Any], 'TemplateConfig']] = None,
|
||||
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
|
||||
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
|
||||
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
|
||||
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
|
||||
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
|
||||
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -538,22 +529,32 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
- WebConfig: Custom configuration
|
||||
context: Context management. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- ManagerConfig: Custom configuration
|
||||
- str: Preset name ("sliding_window", "summarize", "truncate")
|
||||
- Dict[str, Any]: ContextConfig fields
|
||||
- ContextConfig: Custom configuration
|
||||
- ContextManager: Pre-configured instance
|
||||
autonomy: Autonomy settings. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- Dict: Configuration dict
|
||||
- str: Level preset ("suggest", "auto_edit", "full_auto")
|
||||
- Dict[str, Any]: Configuration dict
|
||||
- AutonomyConfig: Custom configuration
|
||||
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
|
||||
Still works for backward compatibility.
|
||||
output: Output configuration. Accepts:
|
||||
- bool: True=default OutputConfig, False=disabled
|
||||
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"verbose": 2, "stream": True})
|
||||
- OutputConfig: Custom configuration
|
||||
Controls: verbose, markdown, stream, metrics, reasoning_steps
|
||||
execution: Execution configuration. Accepts:
|
||||
- bool: True=default ExecutionConfig, False=disabled
|
||||
- str: Preset name ("fast", "balanced", "thorough")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"max_iter": 10, "max_rpm": 60})
|
||||
- ExecutionConfig: Custom configuration
|
||||
Controls: max_iter, max_rpm, max_execution_time, max_retry_limit
|
||||
templates: Template configuration (TemplateConfig).
|
||||
templates: Template configuration. Accepts:
|
||||
- Dict[str, Any]: Template fields (e.g. {"system": "...", "prompt": "..."})
|
||||
- TemplateConfig: Custom configuration
|
||||
Controls: system_template, prompt_template, response_template
|
||||
caching: Caching configuration. Accepts:
|
||||
- bool: True enables with defaults
|
||||
@@ -565,7 +566,9 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
- List[str]: Skill directory paths
|
||||
- SkillsConfig: Custom configuration
|
||||
learn: Continuous learning configuration. Accepts:
|
||||
- bool: True enables with defaults, False disables
|
||||
- bool: True enables with defaults (AGENTIC mode), False disables
|
||||
- str: Mode string ("disabled", "agentic", "propose")
|
||||
- Dict[str, Any]: Config fields (e.g. {"mode": "agentic", "backend": "sqlite"})
|
||||
- LearnConfig: Custom configuration
|
||||
Learning is a first-class citizen, peer to memory. It captures patterns,
|
||||
preferences, and insights from interactions to improve future responses.
|
||||
@@ -790,6 +793,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
|
||||
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
|
||||
output_template = getattr(_output_config, 'template', None) # Response template
|
||||
tool_output_limit = getattr(_output_config, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT)
|
||||
else:
|
||||
# Fallback defaults match silent mode (zero overhead)
|
||||
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
|
||||
@@ -798,6 +802,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
status_trace = False
|
||||
simple_output = False
|
||||
editor_output = False
|
||||
tool_output_limit = DEFAULT_TOOL_OUTPUT_LIMIT
|
||||
|
||||
# Enable editor output mode if configured (beginner-friendly, takes priority)
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
@@ -1068,8 +1073,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
_learn_config = LearnConfig(**learn)
|
||||
elif isinstance(learn, str):
|
||||
# String mode: "disabled", "agentic", "propose"
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
if learn == "disabled":
|
||||
_learn_config = None
|
||||
elif learn == "agentic":
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif learn == "propose":
|
||||
_learn_config = LearnConfig(mode=LearnMode.PROPOSE)
|
||||
else:
|
||||
# Unknown string mode, disable learning
|
||||
_learn_config = None
|
||||
else:
|
||||
_learn_config = learn # Pass through
|
||||
logging.warning(
|
||||
"Unsupported learn= value %r; expected bool, dict, or LearnConfig. "
|
||||
"Learning disabled.", learn
|
||||
)
|
||||
_learn_config = None
|
||||
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
|
||||
# Fallback to memory.learn for backward compatibility
|
||||
if _memory_config.learn:
|
||||
@@ -1518,6 +1539,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
self._init_memory(memory, user_id)
|
||||
self.verbose = verbose
|
||||
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
|
||||
self.tool_output_limit = tool_output_limit # Configurable tool output limit
|
||||
self.allow_delegation = allow_delegation
|
||||
self.step_callback = step_callback
|
||||
# Token budget guard (zero overhead when _max_budget is None)
|
||||
@@ -1642,6 +1664,12 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = approval.backend
|
||||
self._approve_all_tools = approval.all_tools
|
||||
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
|
||||
elif isinstance(approval, dict):
|
||||
# Dict config: convert to ApprovalConfig
|
||||
approval_config = ApprovalConfig(**approval)
|
||||
self._approval_backend = approval_config.backend
|
||||
self._approve_all_tools = approval_config.all_tools
|
||||
self._approval_timeout = approval_config.timeout
|
||||
else:
|
||||
# Plain backend object — dangerous tools only, backend default timeout
|
||||
self._approval_backend = approval
|
||||
@@ -1949,6 +1977,64 @@ Your Goal: {self.goal}
|
||||
elif hasattr(self._context_param, 'process'):
|
||||
# Already a ContextManager instance
|
||||
self._context_manager = self._context_param
|
||||
elif isinstance(self._context_param, str):
|
||||
# String preset: "sliding_window", "summarize", "truncate"
|
||||
from ..config.presets import CONTEXT_PRESETS
|
||||
preset_config = CONTEXT_PRESETS.get(self._context_param)
|
||||
if preset_config is not None:
|
||||
# Convert preset to ContextConfig, then to ManagerConfig
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**preset_config)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
agent_name=self.name or "Agent",
|
||||
session_cache=self._session_dedup_cache,
|
||||
llm_summarize_fn=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Context preset conversion failed: {e}")
|
||||
self._context_manager = None
|
||||
else:
|
||||
# Unknown string preset, disable
|
||||
self._context_manager = None
|
||||
elif isinstance(self._context_param, dict):
|
||||
# Dict config: convert to ContextConfig, then to ManagerConfig
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**self._context_param)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
agent_name=self.name or "Agent",
|
||||
session_cache=self._session_dedup_cache,
|
||||
llm_summarize_fn=self._create_llm_summarize_fn() if llm_summarize_enabled else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Context dict conversion failed: {e}")
|
||||
self._context_manager = None
|
||||
else:
|
||||
# Unknown type, disable
|
||||
self._context_manager = None
|
||||
@@ -2165,6 +2251,25 @@ Summary:"""
|
||||
# Extract verification_hooks from AutonomyConfig if provided
|
||||
if autonomy.verification_hooks and not verification_hooks:
|
||||
self._verification_hooks = autonomy.verification_hooks
|
||||
elif isinstance(autonomy, str):
|
||||
# String preset: "suggest", "auto_edit", "full_auto"
|
||||
from ..config.presets import AUTONOMY_PRESETS
|
||||
preset_config = AUTONOMY_PRESETS.get(autonomy)
|
||||
if preset_config is not None:
|
||||
config = AutonomyConfig.from_dict(preset_config)
|
||||
else:
|
||||
# Unknown string preset — disable autonomy
|
||||
self.autonomy_enabled = False
|
||||
self.autonomy_config = {}
|
||||
self._autonomy_trigger = None
|
||||
self._doom_loop_tracker = None
|
||||
self._file_snapshot = None
|
||||
self._snapshot_stack = []
|
||||
self._redo_stack = []
|
||||
self._autonomy_turn_tool_count = 0
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = False
|
||||
return
|
||||
else:
|
||||
self.autonomy_enabled = False
|
||||
self.autonomy_config = {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -666,7 +667,13 @@ Your Goal: {self.goal}"""
|
||||
self._llm_call_count += 1
|
||||
if self._max_budget and self._total_cost >= self._max_budget:
|
||||
if self._on_budget_exceeded == "stop":
|
||||
raise BudgetExceededError(self.name, self._total_cost, self._max_budget)
|
||||
raise BudgetExceededError(
|
||||
f"Agent '{self.name}' exceeded budget: ${self._total_cost:.4f} >= ${self._max_budget:.4f}",
|
||||
budget_type="cost",
|
||||
limit=self._max_budget,
|
||||
used=self._total_cost,
|
||||
agent_id=self.name
|
||||
)
|
||||
elif self._on_budget_exceeded == "warn":
|
||||
logging.warning(
|
||||
f"[budget] {self.name}: ${self._total_cost:.4f} exceeded "
|
||||
@@ -692,6 +699,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return final_response
|
||||
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
from rich.console import Console
|
||||
@@ -1013,6 +1014,7 @@ Write the complete compiled report:"""
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
import threading
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
@@ -50,6 +50,7 @@ class HandoffConfig:
|
||||
detect_cycles: Enable cycle detection to prevent infinite loops
|
||||
max_depth: Maximum handoff chain depth
|
||||
async_mode: Enable async execution
|
||||
allow_parallel: Enable parallel execution (from Delegator)
|
||||
on_handoff: Callback when handoff starts
|
||||
on_complete: Callback when handoff completes
|
||||
on_error: Callback when handoff fails
|
||||
@@ -62,7 +63,7 @@ class HandoffConfig:
|
||||
|
||||
# Execution control
|
||||
timeout_seconds: float = 300.0
|
||||
max_concurrent: int = 3
|
||||
max_concurrent: int = 5
|
||||
|
||||
# Safety
|
||||
detect_cycles: bool = True
|
||||
@@ -70,6 +71,7 @@ class HandoffConfig:
|
||||
|
||||
# Execution mode
|
||||
async_mode: bool = False
|
||||
allow_parallel: bool = False
|
||||
|
||||
# Callbacks
|
||||
on_handoff: Optional[Callable] = None
|
||||
@@ -88,6 +90,7 @@ class HandoffConfig:
|
||||
"detect_cycles": self.detect_cycles,
|
||||
"max_depth": self.max_depth,
|
||||
"async_mode": self.async_mode,
|
||||
"allow_parallel": self.allow_parallel,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -97,29 +100,13 @@ class HandoffConfig:
|
||||
data["context_policy"] = ContextPolicy(data["context_policy"])
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
class HandoffError(Exception):
|
||||
"""Base exception for handoff errors."""
|
||||
pass
|
||||
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Raised when a cycle is detected in handoff chain."""
|
||||
def __init__(self, chain: List[str]):
|
||||
self.chain = chain
|
||||
super().__init__(f"Handoff cycle detected: {' -> '.join(chain)}")
|
||||
|
||||
class HandoffDepthError(HandoffError):
|
||||
"""Raised when max handoff depth is exceeded."""
|
||||
def __init__(self, depth: int, max_depth: int):
|
||||
self.depth = depth
|
||||
self.max_depth = max_depth
|
||||
super().__init__(f"Max handoff depth exceeded: {depth} > {max_depth}")
|
||||
|
||||
class HandoffTimeoutError(HandoffError):
|
||||
"""Raised when handoff times out."""
|
||||
def __init__(self, timeout: float, agent_name: str):
|
||||
self.timeout = timeout
|
||||
self.agent_name = agent_name
|
||||
super().__init__(f"Handoff to {agent_name} timed out after {timeout}s")
|
||||
# Import structured error hierarchy from central errors module
|
||||
from ..errors import (
|
||||
HandoffError,
|
||||
HandoffCycleError,
|
||||
HandoffDepthError,
|
||||
HandoffTimeoutError
|
||||
)
|
||||
|
||||
# Thread-local storage for tracking handoff chains
|
||||
_handoff_context = threading.local()
|
||||
@@ -267,12 +254,26 @@ class Handoff:
|
||||
if self.config.detect_cycles:
|
||||
chain = _get_handoff_chain()
|
||||
if target_name in chain:
|
||||
raise HandoffCycleError(chain + [target_name])
|
||||
cycle_path = chain + [target_name]
|
||||
raise HandoffCycleError(
|
||||
f"Handoff cycle detected: {' -> '.join(cycle_path)}",
|
||||
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
target_agent=target_name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
cycle_path=cycle_path
|
||||
)
|
||||
|
||||
# Check depth
|
||||
current_depth = _get_handoff_depth()
|
||||
if current_depth >= self.config.max_depth:
|
||||
raise HandoffDepthError(current_depth + 1, self.config.max_depth)
|
||||
raise HandoffDepthError(
|
||||
f"Max handoff depth exceeded: {current_depth + 1} > {self.config.max_depth}",
|
||||
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
target_agent=target_name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
max_depth=self.config.max_depth,
|
||||
current_depth=current_depth + 1
|
||||
)
|
||||
|
||||
def _prepare_context(self, source_agent: 'Agent', kwargs: Dict[str, Any]) -> HandoffInputData:
|
||||
"""
|
||||
@@ -538,7 +539,13 @@ class Handoff:
|
||||
handoff_depth=_get_handoff_depth(),
|
||||
)
|
||||
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
|
||||
raise HandoffTimeoutError(self.config.timeout_seconds, self.agent.name)
|
||||
raise HandoffTimeoutError(
|
||||
f"Handoff to {self.agent.name} timed out after {self.config.timeout_seconds}s",
|
||||
timeout_seconds=self.config.timeout_seconds,
|
||||
source_agent=source_agent.name if hasattr(source_agent, "name") else "unknown",
|
||||
target_agent=self.agent.name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, "name") else "unknown"
|
||||
)
|
||||
except Exception as e:
|
||||
result = HandoffResult(
|
||||
success=False,
|
||||
@@ -818,6 +825,102 @@ When you determine that a task would be better handled by another agent with spe
|
||||
use the transfer tool to hand off the task. The receiving agent will have the full context of
|
||||
the conversation and will continue helping the user."""
|
||||
|
||||
async def parallel_handoffs(
|
||||
source: 'Agent',
|
||||
targets: List[tuple], # [(agent, prompt), ...]
|
||||
max_concurrent: int = 5,
|
||||
config: Optional[HandoffConfig] = None,
|
||||
) -> List['HandoffResult']:
|
||||
"""
|
||||
Execute multiple handoffs in parallel with concurrency control.
|
||||
|
||||
This function provides the parallel execution capabilities from SubagentDelegator
|
||||
while using the unified Handoff system.
|
||||
|
||||
Args:
|
||||
source: Source agent performing the handoffs
|
||||
targets: List of (agent, prompt) tuples for parallel execution
|
||||
max_concurrent: Maximum concurrent handoffs (overrides config)
|
||||
config: Optional HandoffConfig for additional settings
|
||||
|
||||
Returns:
|
||||
List of HandoffResult objects from each parallel handoff
|
||||
|
||||
Example:
|
||||
results = await parallel_handoffs(
|
||||
source=main_agent,
|
||||
targets=[
|
||||
(research_agent, "Research topic X"),
|
||||
(analysis_agent, "Analyze data Y"),
|
||||
(summary_agent, "Summarize findings Z")
|
||||
],
|
||||
max_concurrent=3
|
||||
)
|
||||
"""
|
||||
if not hasattr(source, 'handoff_to_async'):
|
||||
raise ValueError("Source agent must support async handoffs")
|
||||
|
||||
# Check if parallel execution is allowed by config
|
||||
if config and not config.allow_parallel:
|
||||
raise ValueError("Parallel handoffs are disabled in the provided HandoffConfig")
|
||||
|
||||
# Use config's max_concurrent if max_concurrent wasn't explicitly set and config is provided
|
||||
if config and max_concurrent == 5: # Check if default value
|
||||
effective_max_concurrent = config.max_concurrent
|
||||
else:
|
||||
effective_max_concurrent = max_concurrent
|
||||
|
||||
# Create semaphore for concurrency control (0 or negative = unlimited)
|
||||
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
|
||||
|
||||
async def _run_one(agent, prompt):
|
||||
async def _do_handoff():
|
||||
try:
|
||||
return await source.handoff_to_async(agent, prompt, config=config)
|
||||
except Exception as e:
|
||||
logger.error(f"Parallel handoff failed to {getattr(agent, 'name', str(agent))}: {e}")
|
||||
# Return a failed HandoffResult object
|
||||
return HandoffResult(
|
||||
success=False,
|
||||
response="",
|
||||
target_agent=getattr(agent, 'name', str(agent)),
|
||||
source_agent=getattr(source, 'name', str(source)),
|
||||
duration_seconds=0.0,
|
||||
error=str(e),
|
||||
handoff_depth=0
|
||||
)
|
||||
|
||||
if semaphore:
|
||||
async with semaphore:
|
||||
return await _do_handoff()
|
||||
else:
|
||||
return await _do_handoff()
|
||||
|
||||
# Execute all handoffs concurrently
|
||||
tasks = [_run_one(agent, prompt) for agent, prompt in targets]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Convert any exceptions to failed results
|
||||
processed_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, HandoffResult):
|
||||
processed_results.append(result)
|
||||
else:
|
||||
# Handle Exception, BaseException (like CancelledError), or any other unexpected result
|
||||
agent, _prompt = targets[i] # Prefix with _ to mark intentionally unused
|
||||
processed_results.append(HandoffResult(
|
||||
success=False,
|
||||
response="",
|
||||
target_agent=getattr(agent, 'name', str(agent)),
|
||||
source_agent=getattr(source, 'name', str(source)),
|
||||
duration_seconds=0.0,
|
||||
error=str(result),
|
||||
handoff_depth=0
|
||||
))
|
||||
|
||||
return processed_results
|
||||
|
||||
|
||||
def prompt_with_handoff_instructions(base_prompt: str, agent: 'Agent') -> str:
|
||||
"""
|
||||
Add handoff instructions to an agent's prompt.
|
||||
|
||||
@@ -216,10 +216,11 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
# Apply default limit even without context management
|
||||
# This prevents runaway tool outputs from causing overflow
|
||||
if len(result_str) > DEFAULT_TOOL_OUTPUT_LIMIT:
|
||||
limit = getattr(self, 'tool_output_limit', 16000)
|
||||
if len(result_str) > limit:
|
||||
# Use smart truncation format that judge recognizes as OK
|
||||
tail_size = min(DEFAULT_TOOL_OUTPUT_LIMIT // 5, 2000)
|
||||
head = result_str[:DEFAULT_TOOL_OUTPUT_LIMIT - tail_size]
|
||||
tail_size = min(limit // 5, 2000)
|
||||
head = result_str[:limit - tail_size]
|
||||
tail = result_str[-tail_size:] if tail_size > 0 else ""
|
||||
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
|
||||
else:
|
||||
@@ -229,7 +230,7 @@ class ToolExecutionMixin:
|
||||
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 = DEFAULT_TOOL_OUTPUT_LIMIT if not self.context_manager else None
|
||||
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)
|
||||
else:
|
||||
result = truncated
|
||||
|
||||
@@ -108,6 +108,93 @@ def get_multimodal_message(text_prompt: str, images: list) -> list:
|
||||
})
|
||||
return content
|
||||
|
||||
def _prepare_task_prompt(task, task_description, context_text=None):
|
||||
"""
|
||||
Prepare task prompt with context and memory (DRY helper).
|
||||
"""
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
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}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
return task_prompt
|
||||
|
||||
def _execute_with_agent_sync(executor_agent, task_prompt, task, tools, stream=None):
|
||||
"""
|
||||
Execute task with agent synchronously (DRY helper).
|
||||
"""
|
||||
if task.images:
|
||||
return executor_agent.chat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
return executor_agent.chat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
|
||||
async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, stream=None):
|
||||
"""
|
||||
Execute task with agent asynchronously (DRY helper).
|
||||
"""
|
||||
if task.images:
|
||||
return await executor_agent.achat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
return await executor_agent.achat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
|
||||
def process_task_context(context_item, verbose=0, user_id=None):
|
||||
"""
|
||||
Process a single context item for task execution.
|
||||
@@ -703,59 +790,17 @@ class AgentTeam:
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
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}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
# Build task prompt using DRY helper
|
||||
task_prompt = _prepare_task_prompt(task, task_description, context_text)
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task_description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
# Use shared multimodal helper (DRY - defined at module level)
|
||||
agent_output = await executor_agent.achat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
agent_output = await executor_agent.achat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
# Execute with agent using DRY helper (fixes missing stream parameter)
|
||||
agent_output = await _execute_with_agent_async(
|
||||
executor_agent, task_prompt, task, tools, stream=self.stream
|
||||
)
|
||||
|
||||
if agent_output:
|
||||
# Store the response in memory
|
||||
@@ -1066,60 +1111,17 @@ Expected Output: {task.expected_output}."""
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
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}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
# Build task prompt using DRY helper
|
||||
task_prompt = _prepare_task_prompt(task, task_description, context_text)
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
# Use shared multimodal helper (DRY - defined at module level)
|
||||
agent_output = executor_agent.chat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task_id
|
||||
)
|
||||
else:
|
||||
agent_output = executor_agent.chat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=self.stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task_id
|
||||
)
|
||||
# Execute with agent using DRY helper
|
||||
agent_output = _execute_with_agent_sync(
|
||||
executor_agent, task_prompt, task, tools, stream=self.stream
|
||||
)
|
||||
|
||||
if agent_output:
|
||||
# Store the response in memory
|
||||
|
||||
@@ -8,6 +8,7 @@ and context. Inspired by Gemini CLI's delegate-to-agent pattern.
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from enum import Enum
|
||||
@@ -127,6 +128,12 @@ class SubagentDelegator:
|
||||
agent_factory: Factory function to create agents
|
||||
on_task_complete: Callback when task completes
|
||||
"""
|
||||
warnings.warn(
|
||||
"SubagentDelegator is deprecated. Use Agent.handoff_to() or "
|
||||
"parallel_handoffs() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.config = config or DelegationConfig()
|
||||
self.agent_factory = agent_factory
|
||||
self.on_task_complete = on_task_complete
|
||||
|
||||
@@ -50,20 +50,30 @@ logger = get_logger(__name__)
|
||||
|
||||
# ── Singleton registry ───────────────────────────────────────────────────────
|
||||
|
||||
import threading
|
||||
|
||||
_registry: Optional[ApprovalRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
def get_approval_registry() -> ApprovalRegistry:
|
||||
"""Return the global singleton :class:`ApprovalRegistry`."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
with _registry_lock:
|
||||
if _registry is None:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
|
||||
# ── Backward-compatible API (delegates to registry) ─────────────────────────
|
||||
|
||||
# These globals are kept for code that imports them directly.
|
||||
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
|
||||
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
|
||||
# NOTE: Accessing these will trigger lazy initialization of the registry
|
||||
def __getattr__(name):
|
||||
"""Module-level attribute access for lazy initialization."""
|
||||
if name == "APPROVAL_REQUIRED_TOOLS":
|
||||
return get_approval_registry()._required_tools
|
||||
elif name == "TOOL_RISK_LEVELS":
|
||||
return get_approval_registry()._risk_levels
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
# Legacy global callback holder — set_approval_callback wraps it into a backend
|
||||
approval_callback: Optional[Callable] = None
|
||||
@@ -114,14 +124,16 @@ def configure_default_approvals() -> None:
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
|
||||
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
|
||||
get_approval_registry().add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
reg = get_approval_registry()
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
reg._required_tools.add(tool_name)
|
||||
reg._risk_levels[tool_name] = risk_level
|
||||
|
||||
def remove_approval_requirement(tool_name: str) -> None:
|
||||
get_approval_registry().remove_requirement(tool_name)
|
||||
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
|
||||
TOOL_RISK_LEVELS.pop(tool_name, None)
|
||||
reg = get_approval_registry()
|
||||
reg.remove_requirement(tool_name)
|
||||
reg._required_tools.discard(tool_name)
|
||||
reg._risk_levels.pop(tool_name, None)
|
||||
|
||||
def is_approval_required(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_required(tool_name)
|
||||
@@ -157,8 +169,8 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
tool_name = getattr(func, '__name__', str(func))
|
||||
reg = get_approval_registry()
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
reg._required_tools.add(tool_name)
|
||||
reg._risk_levels[tool_name] = risk_level
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
@@ -217,7 +217,11 @@ class CheckpointService:
|
||||
cwd=self.config.workspace_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
raise RuntimeError(f"Git command timed out after 30 seconds: {' '.join(args)}")
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
|
||||
@@ -626,6 +626,16 @@ class OutputConfig:
|
||||
# Example: "# {{title}}\n\n{{content}}\n\n---\nGenerated by AI"
|
||||
template: Optional[str] = None
|
||||
|
||||
# Tool output limit - maximum characters for tool output
|
||||
# Default: 16000 chars (≈ 4000 tokens) to prevent runaway tool outputs
|
||||
tool_output_limit: int = 16000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.tool_output_limit, int) or self.tool_output_limit <= 0:
|
||||
raise ValueError(
|
||||
"OutputConfig.tool_output_limit must be a positive integer."
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -642,6 +652,7 @@ class OutputConfig:
|
||||
"editor_output": self.editor_output,
|
||||
"output_file": self.output_file,
|
||||
"template": self.template,
|
||||
"tool_output_limit": self.tool_output_limit,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -300,9 +300,9 @@ CONTEXT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# =============================================================================
|
||||
|
||||
AUTONOMY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"suggest": {"mode": "suggest"},
|
||||
"auto_edit": {"mode": "auto_edit"},
|
||||
"full_auto": {"mode": "full_auto"},
|
||||
"suggest": {"level": "suggest"},
|
||||
"auto_edit": {"level": "auto_edit"},
|
||||
"full_auto": {"level": "full_auto"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -291,7 +291,20 @@ class MultiAgentLedger:
|
||||
self._shared_tokens: int = 0 # Tokens shared across agents
|
||||
|
||||
def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
|
||||
"""Get or create ledger for an agent."""
|
||||
"""Get or create ledger for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: Unique identifier for the agent.
|
||||
|
||||
Returns:
|
||||
ContextLedgerManager for this agent.
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_id is empty or contains only whitespace.
|
||||
"""
|
||||
if not agent_id or not agent_id.strip():
|
||||
raise ValueError("agent_id must be a non-empty string")
|
||||
|
||||
if agent_id not in self._agents:
|
||||
self._agents[agent_id] = ContextLedgerManager(agent_id)
|
||||
return self._agents[agent_id]
|
||||
|
||||
@@ -514,7 +514,10 @@ class ContextMonitor:
|
||||
return str(output_path)
|
||||
|
||||
def _get_output_path(self, agent_name: str = "") -> Path:
|
||||
"""Get output path, optionally per-agent."""
|
||||
"""Get output path, optionally per-agent.
|
||||
|
||||
Security: Agent names are sanitized to prevent path traversal.
|
||||
"""
|
||||
if not self.multi_agent_files or not agent_name:
|
||||
return self.path
|
||||
|
||||
@@ -522,10 +525,32 @@ class ContextMonitor:
|
||||
stem = self.path.stem
|
||||
suffix = self.path.suffix or (".json" if self.format == "json" else ".txt")
|
||||
|
||||
# Sanitize agent name
|
||||
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())
|
||||
# Sanitize agent name:
|
||||
# 1. Strip any path separators first (defence in depth)
|
||||
safe_name = agent_name.replace('/', '_').replace('\\', '_')
|
||||
# 2. Collapse to safe chars only
|
||||
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
|
||||
# 3. Enforce length limit to prevent filesystem issues
|
||||
safe_name = safe_name[:64] if safe_name else 'unknown'
|
||||
# 4. Strip leading/trailing underscores / dots
|
||||
safe_name = safe_name.strip('_.')
|
||||
if not safe_name:
|
||||
safe_name = 'unknown'
|
||||
|
||||
return self.path.parent / f"{stem}_{safe_name}{suffix}"
|
||||
result = self.path.parent / f"{stem}_{safe_name}{suffix}"
|
||||
|
||||
# Verify the resolved path stays within the expected parent dir
|
||||
resolved = result.resolve()
|
||||
parent_resolved = self.path.parent.resolve()
|
||||
try:
|
||||
resolved.relative_to(parent_resolved)
|
||||
except ValueError:
|
||||
# Fallback to a safe hash-based name
|
||||
import hashlib
|
||||
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
|
||||
result = self.path.parent / f"{stem}_{hash_name}{suffix}"
|
||||
|
||||
return result
|
||||
|
||||
def _get_warnings(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Structured exception hierarchy for PraisonAI SDK.
|
||||
|
||||
Provides uniform error semantics for consistent handling across:
|
||||
- Multi-agent orchestration
|
||||
- Tool execution
|
||||
- LLM interactions
|
||||
- Memory operations
|
||||
- External integrations
|
||||
"""
|
||||
|
||||
from typing import Literal, Protocol, runtime_checkable, Optional, Dict, Any
|
||||
import uuid
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ErrorContextProtocol(Protocol):
|
||||
"""Protocol for structured error context propagation."""
|
||||
|
||||
agent_id: str
|
||||
run_id: str
|
||||
is_retryable: bool
|
||||
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"]
|
||||
|
||||
|
||||
class PraisonAIError(Exception):
|
||||
"""
|
||||
Base error class with structured context for PraisonAI SDK.
|
||||
|
||||
All PraisonAI errors inherit from this to ensure uniform error handling,
|
||||
context propagation, and observability hooks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"] = "validation",
|
||||
is_retryable: bool = False,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.agent_id = agent_id
|
||||
self.run_id = run_id or str(uuid.uuid4())
|
||||
self.error_category = error_category
|
||||
self.is_retryable = is_retryable
|
||||
self.context = context or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.error_category}] {self.message} (agent: {self.agent_id}, run: {self.run_id})"
|
||||
|
||||
|
||||
class ToolExecutionError(PraisonAIError):
|
||||
"""
|
||||
Tool execution failed.
|
||||
|
||||
Includes tool name and execution context for better debugging
|
||||
and selective retry policies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
tool_name: str = "unknown",
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = True, # Most tool errors are retryable
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context["tool_name"] = tool_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="tool",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.tool_name = tool_name
|
||||
|
||||
|
||||
class LLMError(PraisonAIError):
|
||||
"""
|
||||
LLM interaction failed.
|
||||
|
||||
Distinguishes between rate limits (retryable) vs model errors (fatal).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
model_name: str = "unknown",
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = False, # Default to non-retryable unless specified
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context["model_name"] = model_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="llm",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.model_name = model_name
|
||||
|
||||
|
||||
class BudgetExceededError(PraisonAIError):
|
||||
"""
|
||||
Budget limits exceeded (tokens, time, etc).
|
||||
|
||||
Generally not retryable without intervention.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_or_agent_name,
|
||||
total_cost_or_budget_type = None,
|
||||
max_budget_or_limit = None,
|
||||
budget_type: str = "tokens",
|
||||
limit: Optional[float] = None,
|
||||
used: Optional[float] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
# Handle backward compatibility: old constructor BudgetExceededError(agent_name, total_cost, max_budget)
|
||||
if (isinstance(message_or_agent_name, str) and
|
||||
isinstance(total_cost_or_budget_type, (int, float)) and
|
||||
isinstance(max_budget_or_limit, (int, float)) and
|
||||
budget_type == "tokens"): # Default value indicates old constructor
|
||||
|
||||
# Old constructor format
|
||||
agent_name = message_or_agent_name
|
||||
total_cost = float(total_cost_or_budget_type)
|
||||
max_budget = float(max_budget_or_limit)
|
||||
message = f"Agent '{agent_name}' exceeded budget: ${total_cost:.4f} >= ${max_budget:.4f}"
|
||||
|
||||
context = context or {}
|
||||
context.update({
|
||||
"budget_type": "cost", # Legacy errors are cost-based
|
||||
"limit": max_budget,
|
||||
"used": total_cost
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_name,
|
||||
run_id=run_id,
|
||||
error_category="budget",
|
||||
is_retryable=False,
|
||||
context=context
|
||||
)
|
||||
self.budget_type = "cost"
|
||||
self.limit = max_budget
|
||||
self.used = total_cost
|
||||
self.agent_name = agent_name
|
||||
self.total_cost = total_cost
|
||||
self.max_budget = max_budget
|
||||
else:
|
||||
# New constructor format
|
||||
message = str(message_or_agent_name)
|
||||
if total_cost_or_budget_type is not None and isinstance(total_cost_or_budget_type, str):
|
||||
budget_type = total_cost_or_budget_type
|
||||
if max_budget_or_limit is not None:
|
||||
if limit is None:
|
||||
limit = max_budget_or_limit
|
||||
|
||||
context = context or {}
|
||||
context.update({
|
||||
"budget_type": budget_type,
|
||||
"limit": limit,
|
||||
"used": used
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="budget",
|
||||
is_retryable=False,
|
||||
context=context
|
||||
)
|
||||
self.budget_type = budget_type
|
||||
self.limit = limit
|
||||
self.used = used
|
||||
|
||||
# Legacy attributes for backward compatibility
|
||||
self.agent_name = agent_id
|
||||
self.total_cost = used
|
||||
self.max_budget = limit
|
||||
|
||||
|
||||
class ValidationError(PraisonAIError):
|
||||
"""
|
||||
Input validation failed.
|
||||
|
||||
Usually indicates programming errors, not retryable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field_name: Optional[str] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
if field_name:
|
||||
context["field_name"] = field_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="validation",
|
||||
is_retryable=False, # Validation errors need code fixes
|
||||
context=context
|
||||
)
|
||||
self.field_name = field_name
|
||||
|
||||
|
||||
class NetworkError(PraisonAIError):
|
||||
"""
|
||||
Network/external service error.
|
||||
|
||||
Often retryable with backoff.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
service_name: str = "unknown",
|
||||
status_code: Optional[int] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = True, # Most network errors are retryable
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context.update({
|
||||
"service_name": service_name,
|
||||
"status_code": status_code
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="network",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.service_name = service_name
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HandoffError(PraisonAIError):
|
||||
"""
|
||||
Agent handoff/delegation failed.
|
||||
|
||||
Includes source/target agent context for multi-agent debugging.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
source_agent: str = "unknown",
|
||||
target_agent: Optional[str] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = False, # Handoff errors usually need investigation
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context.update({
|
||||
"source_agent": source_agent,
|
||||
"target_agent": target_agent
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="handoff",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.source_agent = source_agent
|
||||
self.target_agent = target_agent
|
||||
|
||||
|
||||
# Specialized handoff errors (maintain backward compatibility)
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Circular handoff dependency detected."""
|
||||
|
||||
def __init__(self, message: str, cycle_path: Optional[list] = None, **kwargs):
|
||||
super().__init__(message, **kwargs)
|
||||
if cycle_path:
|
||||
self.context["cycle_path"] = cycle_path
|
||||
# Backward compatibility alias
|
||||
self.chain = cycle_path
|
||||
|
||||
|
||||
class HandoffDepthError(HandoffError):
|
||||
"""Maximum handoff depth exceeded."""
|
||||
|
||||
def __init__(self, message: str, max_depth: Optional[int] = None, current_depth: Optional[int] = None, **kwargs):
|
||||
super().__init__(message, **kwargs)
|
||||
self.context.update({
|
||||
"max_depth": max_depth,
|
||||
"current_depth": current_depth
|
||||
})
|
||||
# Backward compatibility aliases
|
||||
self.max_depth = max_depth
|
||||
self.depth = current_depth
|
||||
|
||||
|
||||
class HandoffTimeoutError(HandoffError):
|
||||
"""Handoff operation timed out."""
|
||||
|
||||
def __init__(self, message: str, timeout_seconds: Optional[float] = None, **kwargs):
|
||||
super().__init__(message, is_retryable=True, **kwargs) # Timeouts may be retryable
|
||||
if timeout_seconds:
|
||||
self.context["timeout_seconds"] = timeout_seconds
|
||||
# Backward compatibility alias
|
||||
self.timeout = timeout_seconds
|
||||
|
||||
|
||||
# Export all error types for easy importing
|
||||
__all__ = [
|
||||
"ErrorContextProtocol",
|
||||
"PraisonAIError",
|
||||
"ToolExecutionError",
|
||||
"LLMError",
|
||||
"BudgetExceededError",
|
||||
"ValidationError",
|
||||
"NetworkError",
|
||||
"HandoffError",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffTimeoutError"
|
||||
]
|
||||
@@ -291,21 +291,6 @@ class ObservabilityHooks:
|
||||
"final_stage": self._current_stage.name if self._current_stage else None,
|
||||
}
|
||||
|
||||
# Global hooks instance (opt-in)
|
||||
_global_hooks: Optional[ObservabilityHooks] = None
|
||||
|
||||
def get_hooks() -> Optional[ObservabilityHooks]:
|
||||
"""Get global observability hooks."""
|
||||
return _global_hooks
|
||||
|
||||
def enable_observability() -> ObservabilityHooks:
|
||||
"""Enable global observability."""
|
||||
global _global_hooks
|
||||
if _global_hooks is None:
|
||||
_global_hooks = ObservabilityHooks(enabled=True)
|
||||
return _global_hooks
|
||||
|
||||
def disable_observability():
|
||||
"""Disable global observability."""
|
||||
global _global_hooks
|
||||
_global_hooks = None
|
||||
# NOTE: Observability is now per-agent/per-pipeline to comply with multi-agent safety.
|
||||
# Each agent creates its own ObservabilityHooks instance when observe=True.
|
||||
# See agent.py:2236 for the correct per-instance pattern.
|
||||
@@ -5,6 +5,7 @@ Manages registration and lookup of hooks for different events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Dict, List, Optional, Callable, Union
|
||||
from functools import wraps
|
||||
@@ -319,18 +320,21 @@ class HookRegistry:
|
||||
|
||||
# Global default registry
|
||||
_default_registry: Optional[HookRegistry] = None
|
||||
_default_registry_lock = threading.Lock()
|
||||
|
||||
def get_default_registry() -> HookRegistry:
|
||||
"""Get the default global hook registry."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
with _default_registry_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
|
||||
def set_default_registry(registry: HookRegistry):
|
||||
"""Set the default global hook registry."""
|
||||
global _default_registry
|
||||
_default_registry = registry
|
||||
with _default_registry_lock:
|
||||
_default_registry = registry
|
||||
|
||||
# =============================================================================
|
||||
# Simplified API (beginner-friendly aliases)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Knowledge Adapter Registry
|
||||
|
||||
Provides a registry system for knowledge adapters to replace hardcoded imports
|
||||
in knowledge/knowledge.py. This enables protocol-driven knowledge backends without
|
||||
core modifications.
|
||||
"""
|
||||
|
||||
from typing import Callable, List, Optional, Tuple, Type
|
||||
from ...utils.adapter_registry import AdapterRegistry
|
||||
from ..protocols import KnowledgeStoreProtocol
|
||||
|
||||
|
||||
class KnowledgeAdapterRegistry(AdapterRegistry[KnowledgeStoreProtocol]):
|
||||
"""
|
||||
Thread-safe registry for knowledge adapters.
|
||||
|
||||
Replaces hardcoded imports and configurations in knowledge.py
|
||||
with dynamic adapter discovery and instantiation.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Register adapter (typically done on import)
|
||||
register_knowledge_adapter("sqlite", SQLiteKnowledgeAdapter)
|
||||
register_knowledge_adapter("chroma", ChromaAdapter) # in wrapper
|
||||
|
||||
# Use in Knowledge class
|
||||
adapter = get_knowledge_adapter("chroma", config=config)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(adapter_type_name="Knowledge")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_knowledge_registry = KnowledgeAdapterRegistry()
|
||||
|
||||
|
||||
def register_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
|
||||
"""Register a knowledge adapter class."""
|
||||
_knowledge_registry.register_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def register_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
|
||||
"""Register a knowledge adapter factory function."""
|
||||
_knowledge_registry.register_factory(name, factory_func)
|
||||
|
||||
|
||||
def get_knowledge_adapter(name: str, **kwargs) -> Optional[KnowledgeStoreProtocol]:
|
||||
"""Get knowledge adapter instance by name."""
|
||||
return _knowledge_registry.get_adapter(name, **kwargs)
|
||||
|
||||
|
||||
def list_knowledge_adapters() -> List[str]:
|
||||
"""List all registered knowledge adapter names."""
|
||||
return _knowledge_registry.list_adapters()
|
||||
|
||||
|
||||
def get_first_available_knowledge_adapter(
|
||||
preferences: Optional[List[str]] = None, **kwargs
|
||||
) -> Optional[Tuple[str, KnowledgeStoreProtocol]]:
|
||||
"""
|
||||
Get first available knowledge adapter.
|
||||
|
||||
Args:
|
||||
preferences: List of adapter names to try (defaults to ["mem0", "mongodb"])
|
||||
**kwargs: Configuration passed to adapter constructor
|
||||
|
||||
Returns:
|
||||
(adapter_name, adapter_instance) tuple or None
|
||||
"""
|
||||
if preferences is None:
|
||||
# Default to actual available adapters per knowledge/adapters/__init__.py
|
||||
preferences = ["mem0", "mongodb"]
|
||||
|
||||
return _knowledge_registry.get_first_available(preferences, **kwargs)
|
||||
|
||||
|
||||
# Canonical aliases per AGENTS.md naming conventions
|
||||
def add_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
|
||||
"""Canonical alias for register_knowledge_adapter (preferred naming per AGENTS.md)."""
|
||||
return register_knowledge_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def add_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
|
||||
"""Canonical alias for register_knowledge_factory (preferred naming per AGENTS.md)."""
|
||||
return register_knowledge_factory(name, factory_func)
|
||||
|
||||
|
||||
def has_knowledge_adapter(name: str) -> bool:
|
||||
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
|
||||
return _knowledge_registry.is_available(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'KnowledgeAdapterRegistry',
|
||||
'register_knowledge_adapter',
|
||||
'register_knowledge_factory',
|
||||
'get_knowledge_adapter',
|
||||
'list_knowledge_adapters',
|
||||
'get_first_available_knowledge_adapter',
|
||||
# Canonical aliases (preferred)
|
||||
'add_knowledge_adapter',
|
||||
'add_knowledge_factory',
|
||||
'has_knowledge_adapter',
|
||||
]
|
||||
@@ -98,6 +98,38 @@ def __getattr__(name):
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
return TokenUsage
|
||||
elif name == "LLMProviderProtocol":
|
||||
from .protocols import LLMProviderProtocol
|
||||
_lazy_cache[name] = LLMProviderProtocol
|
||||
return LLMProviderProtocol
|
||||
elif name == "ModelCapabilitiesProtocol":
|
||||
from .protocols import ModelCapabilitiesProtocol
|
||||
_lazy_cache[name] = ModelCapabilitiesProtocol
|
||||
return ModelCapabilitiesProtocol
|
||||
elif name == "LLMRateLimiterProtocol":
|
||||
from .protocols import LLMRateLimiterProtocol
|
||||
_lazy_cache[name] = LLMRateLimiterProtocol
|
||||
return LLMRateLimiterProtocol
|
||||
elif name == "LLMFailoverProtocol":
|
||||
from .protocols import LLMFailoverProtocol
|
||||
_lazy_cache[name] = LLMFailoverProtocol
|
||||
return LLMFailoverProtocol
|
||||
elif name == "LLMProviderError":
|
||||
from .protocols import LLMProviderError
|
||||
_lazy_cache[name] = LLMProviderError
|
||||
return LLMProviderError
|
||||
elif name == "RateLimitError":
|
||||
from .protocols import RateLimitError
|
||||
_lazy_cache[name] = RateLimitError
|
||||
return RateLimitError
|
||||
elif name == "ModelNotAvailableError":
|
||||
from .protocols import ModelNotAvailableError
|
||||
_lazy_cache[name] = ModelNotAvailableError
|
||||
return ModelNotAvailableError
|
||||
elif name == "ContextLengthExceededError":
|
||||
from .protocols import ContextLengthExceededError
|
||||
_lazy_cache[name] = ContextLengthExceededError
|
||||
return ContextLengthExceededError
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -122,5 +154,15 @@ __all__ = [
|
||||
"TaskComplexity",
|
||||
"create_routing_agent",
|
||||
"RateLimiter",
|
||||
"TokenUsage"
|
||||
"TokenUsage",
|
||||
# Protocols
|
||||
"LLMProviderProtocol",
|
||||
"ModelCapabilitiesProtocol",
|
||||
"LLMRateLimiterProtocol",
|
||||
"LLMFailoverProtocol",
|
||||
# Exceptions
|
||||
"LLMProviderError",
|
||||
"RateLimitError",
|
||||
"ModelNotAvailableError",
|
||||
"ContextLengthExceededError"
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
LLM Provider Adapters
|
||||
|
||||
Concrete implementations of LLMProviderAdapter protocol that replace
|
||||
scattered provider dispatch logic throughout the core.
|
||||
|
||||
This demonstrates the protocol-driven approach for Gap 2.
|
||||
"""
|
||||
|
||||
from ..protocols import LLMProviderProtocol
|
||||
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class DefaultAdapter:
|
||||
"""Default provider adapter with sensible fallbacks."""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 5 # Conservative default
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return tools # No special formatting by default
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
pass # No post-processing by default
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return False # Conservative default
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
return True # Most providers support streaming
|
||||
|
||||
|
||||
class OllamaAdapter(DefaultAdapter):
|
||||
"""
|
||||
Ollama-specific provider adapter.
|
||||
|
||||
Demonstrates how to extract Ollama-specific logic from llm.py
|
||||
scattered provider dispatch into a clean adapter.
|
||||
"""
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
# Replaces: OLLAMA_SUMMARY_ITERATION_THRESHOLD logic
|
||||
# Must match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD = 1
|
||||
return iter_count >= 1
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
# Replaces: Ollama-specific post-tool summary branches
|
||||
if (not state.get('response_text', '').strip() and
|
||||
state.get('formatted_tools') and
|
||||
state.get('iteration_count') == 0):
|
||||
# Add Ollama-specific summary logic here
|
||||
state['needs_summary'] = True
|
||||
|
||||
|
||||
class AnthropicAdapter(DefaultAdapter):
|
||||
"""Anthropic/Claude provider adapter."""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return True # Claude supports prompt caching
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class GeminiAdapter(DefaultAdapter):
|
||||
"""Google Gemini provider adapter."""
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Replaces: gemini_internal_tools handling in llm.py
|
||||
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
if tool.get('name') in GEMINI_INTERNAL_TOOLS:
|
||||
# Convert to Gemini internal tool format
|
||||
formatted.append({
|
||||
'type': 'function',
|
||||
'function': tool
|
||||
})
|
||||
else:
|
||||
formatted.append(tool)
|
||||
return formatted
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# Provider adapter registry - public for extension
|
||||
_provider_adapters: Dict[str, LLMProviderProtocol] = {}
|
||||
|
||||
# Register core adapters at import time
|
||||
_default_adapter = DefaultAdapter()
|
||||
_provider_adapters['default'] = _default_adapter
|
||||
_provider_adapters['ollama'] = OllamaAdapter()
|
||||
_provider_adapters['anthropic'] = AnthropicAdapter()
|
||||
_provider_adapters['claude'] = AnthropicAdapter() # Alias
|
||||
_provider_adapters['gemini'] = GeminiAdapter()
|
||||
|
||||
|
||||
def add_provider_adapter(name: str, adapter: LLMProviderProtocol) -> None:
|
||||
"""
|
||||
Register a provider adapter by name.
|
||||
|
||||
This enables new providers to be added without modifying core code.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., "cohere", "huggingface")
|
||||
adapter: Provider adapter implementing LLMProviderProtocol
|
||||
"""
|
||||
_provider_adapters[name] = adapter
|
||||
|
||||
|
||||
def get_provider_adapter(name: str) -> LLMProviderProtocol:
|
||||
"""
|
||||
Get provider adapter by name with fallback to default.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., "anthropic", "ollama", "gemini")
|
||||
|
||||
Returns:
|
||||
Provider adapter instance (default if name not found)
|
||||
"""
|
||||
return _provider_adapters.get(name, _provider_adapters['default'])
|
||||
|
||||
|
||||
def list_provider_adapters() -> List[str]:
|
||||
"""List all registered provider adapter names."""
|
||||
return sorted(_provider_adapters.keys())
|
||||
|
||||
|
||||
def has_provider_adapter(name: str) -> bool:
|
||||
"""Check if a provider adapter is registered."""
|
||||
return name in _provider_adapters
|
||||
|
||||
|
||||
__all__ = [
|
||||
'DefaultAdapter',
|
||||
'OllamaAdapter',
|
||||
'AnthropicAdapter',
|
||||
'GeminiAdapter',
|
||||
'get_provider_adapter',
|
||||
'add_provider_adapter',
|
||||
'list_provider_adapters',
|
||||
'has_provider_adapter',
|
||||
]
|
||||
@@ -11,6 +11,7 @@ from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Callable, Tuple, TYPE_CHECKING
|
||||
from pydantic import BaseModel
|
||||
from dataclasses import dataclass
|
||||
@@ -2197,6 +2198,7 @@ class OpenAIClient:
|
||||
# Global client instance (similar to main.py pattern)
|
||||
_global_client = None
|
||||
_global_client_params = None
|
||||
_global_client_lock = threading.Lock()
|
||||
|
||||
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> OpenAIClient:
|
||||
"""
|
||||
@@ -2216,9 +2218,10 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
|
||||
normalized_base_url = base_url
|
||||
current_params = (normalized_api_key, normalized_base_url)
|
||||
|
||||
# Only create new client if parameters changed or first time
|
||||
if _global_client is None or _global_client_params != current_params:
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
_global_client_params = current_params
|
||||
|
||||
return _global_client
|
||||
# Thread-safe client creation
|
||||
with _global_client_lock:
|
||||
# Only create new client if parameters changed or first time
|
||||
if _global_client is None or _global_client_params != current_params:
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
_global_client_params = current_params
|
||||
return _global_client
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
LLM Provider Protocols for PraisonAI Agents.
|
||||
|
||||
Defines minimal protocol interfaces for LLM providers to enable
|
||||
extensibility without vendor lock-in to litellm or any specific provider.
|
||||
|
||||
No heavy imports - only stdlib and typing.
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Protocol, Union, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol defining the interface that LLM providers must implement.
|
||||
|
||||
This enables switching between different LLM backends (litellm, openai,
|
||||
anthropic, local models, etc.) without modifying core agent code.
|
||||
|
||||
Implementations must provide both sync and async variants for
|
||||
production flexibility.
|
||||
"""
|
||||
|
||||
model: str
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Generate chat completion.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
tools: Optional list of tool schemas for function calling
|
||||
temperature: Sampling temperature (0.0-2.0)
|
||||
max_tokens: Maximum tokens to generate
|
||||
stream: Whether to return streaming iterator
|
||||
**kwargs: Provider-specific options
|
||||
|
||||
Returns:
|
||||
Single response dict or streaming iterator of response chunks
|
||||
"""
|
||||
...
|
||||
|
||||
async def achat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Async version of chat.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
tools: Optional list of tool schemas for function calling
|
||||
temperature: Sampling temperature (0.0-2.0)
|
||||
max_tokens: Maximum tokens to generate
|
||||
stream: Whether to return async streaming iterator
|
||||
**kwargs: Provider-specific options
|
||||
|
||||
Returns:
|
||||
Single response dict or async streaming iterator of response chunks
|
||||
"""
|
||||
...
|
||||
|
||||
def get_token_count(self, text: str) -> int:
|
||||
"""
|
||||
Count tokens in text for the current model.
|
||||
|
||||
Args:
|
||||
text: Text to count tokens for
|
||||
|
||||
Returns:
|
||||
Number of tokens
|
||||
"""
|
||||
...
|
||||
|
||||
def get_context_length(self) -> int:
|
||||
"""
|
||||
Get maximum context length for the current model.
|
||||
|
||||
Returns:
|
||||
Maximum context length in tokens
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelCapabilitiesProtocol(Protocol):
|
||||
"""
|
||||
Protocol for querying model capabilities.
|
||||
|
||||
Enables runtime capability detection for different models
|
||||
without hardcoded capability tables.
|
||||
"""
|
||||
|
||||
def supports_streaming(self, model: str) -> bool:
|
||||
"""Check if model supports streaming responses."""
|
||||
...
|
||||
|
||||
def supports_function_calling(self, model: str) -> bool:
|
||||
"""Check if model supports function/tool calling."""
|
||||
...
|
||||
|
||||
def supports_structured_output(self, model: str) -> bool:
|
||||
"""Check if model supports structured JSON output."""
|
||||
...
|
||||
|
||||
def supports_vision(self, model: str) -> bool:
|
||||
"""Check if model supports vision/image inputs."""
|
||||
...
|
||||
|
||||
def get_max_tokens(self, model: str) -> Optional[int]:
|
||||
"""Get maximum context length for model."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMRateLimiterProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LLM request rate limiting.
|
||||
|
||||
Enables different rate limiting strategies without
|
||||
coupling to specific implementations.
|
||||
"""
|
||||
|
||||
async def acquire(self, tokens: int = 1) -> None:
|
||||
"""
|
||||
Acquire permission to make request.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Raises:
|
||||
RateLimitError: If rate limit would be exceeded
|
||||
"""
|
||||
...
|
||||
|
||||
def can_proceed(self, tokens: int = 1) -> bool:
|
||||
"""
|
||||
Check if request can proceed without blocking.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Returns:
|
||||
True if request can proceed immediately
|
||||
"""
|
||||
...
|
||||
|
||||
def get_wait_time(self, tokens: int = 1) -> float:
|
||||
"""
|
||||
Get seconds to wait before request can proceed.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Returns:
|
||||
Seconds to wait (0.0 if can proceed immediately)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMFailoverProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LLM failover strategies.
|
||||
|
||||
Enables automatic fallback to alternative models
|
||||
when primary models fail or are unavailable.
|
||||
"""
|
||||
|
||||
def get_fallback_model(self, failed_model: str, error: Exception) -> Optional[str]:
|
||||
"""
|
||||
Get fallback model for failed request.
|
||||
|
||||
Args:
|
||||
failed_model: Model that failed
|
||||
error: Exception that occurred
|
||||
|
||||
Returns:
|
||||
Alternative model name or None if no fallback available
|
||||
"""
|
||||
...
|
||||
|
||||
def should_retry(self, error: Exception) -> bool:
|
||||
"""
|
||||
Check if error indicates retryable failure.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
|
||||
Returns:
|
||||
True if should retry with fallback
|
||||
"""
|
||||
...
|
||||
|
||||
def get_retry_delay(self, attempt: int) -> float:
|
||||
"""
|
||||
Get delay before retry attempt.
|
||||
|
||||
Args:
|
||||
attempt: Retry attempt number (1-based)
|
||||
|
||||
Returns:
|
||||
Seconds to wait before retry
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class LLMProviderError(Exception):
|
||||
"""Base exception for LLM provider errors."""
|
||||
|
||||
def __init__(self, message: str, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RateLimitError(LLMProviderError):
|
||||
"""Raised when rate limit is exceeded."""
|
||||
|
||||
def __init__(self, message: Optional[str] = None, retry_after: Optional[float] = None, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(message or "Rate limit exceeded", provider=provider, model=model)
|
||||
|
||||
|
||||
class ModelNotAvailableError(LLMProviderError):
|
||||
"""Raised when requested model is not available."""
|
||||
|
||||
def __init__(self, model: str, provider: Optional[str] = None):
|
||||
message = f"Model '{model}' is not available{f' from provider {provider}' if provider else ''}"
|
||||
super().__init__(message, provider=provider, model=model)
|
||||
|
||||
|
||||
class ContextLengthExceededError(LLMProviderError):
|
||||
"""Raised when input exceeds model's context length."""
|
||||
|
||||
def __init__(self, tokens: int, max_tokens: int, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.tokens = tokens
|
||||
self.max_tokens = max_tokens
|
||||
super().__init__(f"Input length ({tokens} tokens) exceeds model limit ({max_tokens} tokens)", provider=provider, model=model)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol for provider-specific LLM adaptations.
|
||||
|
||||
This replaces scattered provider dispatch logic throughout the core.
|
||||
Each provider implements these hooks to handle provider-specific quirks
|
||||
without requiring if/elif branching in the core chat loop.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class OllamaAdapter:
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 3 # Ollama-specific threshold
|
||||
|
||||
def format_tools(self, tools) -> list:
|
||||
return tools # No special formatting needed
|
||||
|
||||
def post_tool_iteration(self, state) -> None:
|
||||
if state.get('empty_response') and state.get('tools'):
|
||||
# Add Ollama-specific tool summary
|
||||
state['summary'] = "Tool results processed"
|
||||
```
|
||||
"""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
"""Check if this provider supports prompt caching."""
|
||||
...
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
"""Check if tools should be summarized at this iteration count."""
|
||||
...
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Format tools for this provider's requirements."""
|
||||
...
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
"""Handle provider-specific post-tool processing."""
|
||||
...
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
"""Check if provider supports structured JSON output."""
|
||||
...
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Check if provider supports streaming responses."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UnifiedLLMProtocol(Protocol):
|
||||
"""
|
||||
Unified protocol for LLM dispatch that consolidates the dual execution paths.
|
||||
|
||||
This replaces the separate custom-LLM path (LLM.get_response) and OpenAI path
|
||||
(OpenAIClient) with a single async-first protocol that all providers implement.
|
||||
|
||||
Sync implementations must not call asyncio.run() from library internals.
|
||||
Provide native sync implementations or convert streaming responses
|
||||
to real Iterator[Dict[str, Any]] objects.
|
||||
"""
|
||||
|
||||
async def achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Primary async chat completion method.
|
||||
All LLM interactions go through this single path.
|
||||
"""
|
||||
...
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
**kwargs: Any
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Synchronous chat completion entry point.
|
||||
For streaming, implementations must return a real Iterator[Dict[str, Any]].
|
||||
"""
|
||||
...
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import List, Optional, Dict, Any, Union, Literal, Type
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -25,13 +26,16 @@ except ImportError:
|
||||
|
||||
# Global list to store error logs
|
||||
error_logs = []
|
||||
_error_logs_lock = threading.Lock()
|
||||
|
||||
# Separate registries for sync and async callbacks
|
||||
sync_display_callbacks = {}
|
||||
async_display_callbacks = {}
|
||||
_callbacks_lock = threading.Lock()
|
||||
|
||||
# Global approval callback registry
|
||||
approval_callback = None
|
||||
_approval_callback_lock = threading.Lock()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PraisonAI Unique Color Palette: "Elegant Intelligence"
|
||||
@@ -128,10 +132,11 @@ def register_display_callback(display_type: str, callback_fn, is_async: bool = F
|
||||
callback_fn: The callback function to register
|
||||
is_async (bool): Whether the callback is asynchronous
|
||||
"""
|
||||
if is_async:
|
||||
async_display_callbacks[display_type] = callback_fn
|
||||
else:
|
||||
sync_display_callbacks[display_type] = callback_fn
|
||||
with _callbacks_lock:
|
||||
if is_async:
|
||||
async_display_callbacks[display_type] = callback_fn
|
||||
else:
|
||||
sync_display_callbacks[display_type] = callback_fn
|
||||
|
||||
def register_approval_callback(callback_fn):
|
||||
"""Register a global approval callback function for dangerous tool operations.
|
||||
@@ -140,7 +145,8 @@ def register_approval_callback(callback_fn):
|
||||
callback_fn: Function that takes (function_name, arguments, risk_level) and returns ApprovalDecision
|
||||
"""
|
||||
global approval_callback
|
||||
approval_callback = callback_fn
|
||||
with _approval_callback_lock:
|
||||
approval_callback = callback_fn
|
||||
|
||||
# Simplified aliases (consistent naming convention)
|
||||
add_display_callback = register_display_callback
|
||||
@@ -155,8 +161,10 @@ def execute_sync_callback(display_type: str, **kwargs):
|
||||
display_type (str): Type of display event
|
||||
**kwargs: Arguments to pass to the callback function
|
||||
"""
|
||||
if display_type in sync_display_callbacks:
|
||||
callback = sync_display_callbacks[display_type]
|
||||
with _callbacks_lock:
|
||||
callback = sync_display_callbacks.get(display_type)
|
||||
|
||||
if callback:
|
||||
import inspect
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
@@ -180,9 +188,12 @@ async def execute_callback(display_type: str, **kwargs):
|
||||
import inspect
|
||||
|
||||
# Execute synchronous callback if registered
|
||||
if display_type in sync_display_callbacks:
|
||||
callback = sync_display_callbacks[display_type]
|
||||
sig = inspect.signature(callback)
|
||||
with _callbacks_lock:
|
||||
sync_callback = sync_display_callbacks.get(display_type)
|
||||
async_callback = async_display_callbacks.get(display_type)
|
||||
|
||||
if sync_callback:
|
||||
sig = inspect.signature(sync_callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
@@ -193,11 +204,11 @@ async def execute_callback(display_type: str, **kwargs):
|
||||
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: callback(**supported_kwargs))
|
||||
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
|
||||
|
||||
# Execute asynchronous callback if registered
|
||||
if display_type in async_display_callbacks:
|
||||
callback = async_display_callbacks[display_type]
|
||||
if async_callback:
|
||||
callback = async_callback
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
@@ -418,7 +429,8 @@ def display_error(message: str, console=None):
|
||||
title="⚠ Error",
|
||||
border_style=PRAISON_COLORS["error"]
|
||||
))
|
||||
error_logs.append(message)
|
||||
with _error_logs_lock:
|
||||
error_logs.append(message)
|
||||
|
||||
def display_generating(content: str = "", start_time: Optional[float] = None):
|
||||
if not content or not str(content).strip():
|
||||
@@ -615,7 +627,8 @@ async def adisplay_error(message: str, console=None):
|
||||
await execute_callback('error', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
|
||||
error_logs.append(message)
|
||||
with _error_logs_lock:
|
||||
error_logs.append(message)
|
||||
|
||||
async def adisplay_generating(content: str = "", start_time: Optional[float] = None):
|
||||
"""Async version of display_generating."""
|
||||
|
||||
@@ -5,15 +5,37 @@ This module provides concrete implementations of MemoryProtocol
|
||||
for different backends, following the adapter pattern used in knowledge/.
|
||||
|
||||
Adapters:
|
||||
- sqlite_adapter: SQLite-based memory storage
|
||||
- chromadb_adapter: ChromaDB vector memory storage
|
||||
- mongodb_adapter: MongoDB document storage
|
||||
- redis_adapter: Redis in-memory storage
|
||||
- postgres_adapter: PostgreSQL storage
|
||||
- sqlite_adapter: SQLite-based memory storage (core)
|
||||
- in_memory_adapter: Lightweight in-memory storage (core, for testing/dev)
|
||||
- chromadb_adapter: ChromaDB vector memory storage (wrapper)
|
||||
- mongodb_adapter: MongoDB document storage (wrapper)
|
||||
- redis_adapter: Redis in-memory storage (wrapper)
|
||||
- postgres_adapter: PostgreSQL storage (wrapper)
|
||||
|
||||
Heavy adapters (chromadb, mongodb, etc.) are moved to wrapper package
|
||||
to keep core lightweight and protocol-driven.
|
||||
"""
|
||||
|
||||
from .sqlite_adapter import SqliteMemoryAdapter
|
||||
from .in_memory_adapter import InMemoryAdapter
|
||||
from .registry import (
|
||||
register_memory_adapter,
|
||||
register_memory_factory,
|
||||
get_memory_adapter,
|
||||
list_memory_adapters,
|
||||
get_first_available_memory_adapter,
|
||||
)
|
||||
|
||||
# Register core adapters
|
||||
register_memory_adapter("sqlite", SqliteMemoryAdapter)
|
||||
register_memory_adapter("in_memory", InMemoryAdapter)
|
||||
|
||||
__all__ = [
|
||||
'SqliteMemoryAdapter',
|
||||
]
|
||||
'InMemoryAdapter',
|
||||
'register_memory_adapter',
|
||||
'register_memory_factory',
|
||||
'get_memory_adapter',
|
||||
'list_memory_adapters',
|
||||
'get_first_available_memory_adapter',
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
In-Memory Memory Adapter for PraisonAI.
|
||||
|
||||
Lightweight in-memory adapter for testing and development.
|
||||
No dependencies required - stores data as plain Python lists.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class InMemoryAdapter:
|
||||
"""
|
||||
Lightweight in-memory adapter for testing and development.
|
||||
|
||||
Stores short-term and long-term memories as plain Python lists.
|
||||
Data is not persisted across process restarts.
|
||||
|
||||
Usage:
|
||||
adapter = InMemoryAdapter()
|
||||
adapter.store_short_term("hello", {"source": "test"})
|
||||
results = adapter.search_short_term("hello")
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
|
||||
def store_short_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._data.append(entry)
|
||||
return entry["id"]
|
||||
|
||||
def search_short_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "short" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def store_long_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._data.append(entry)
|
||||
return entry["id"]
|
||||
|
||||
def search_long_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "long" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
# Return defensive copy to prevent external mutation of internal state
|
||||
return [dict(entry) for entry in self._data]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Memory Adapter Registry
|
||||
|
||||
Provides a registry system for memory adapters to replace hardcoded imports
|
||||
in memory/memory.py. This enables protocol-driven memory backends without
|
||||
core modifications.
|
||||
"""
|
||||
|
||||
from typing import Callable, List, Optional, Tuple, Type
|
||||
from ...utils.adapter_registry import AdapterRegistry
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
|
||||
class MemoryAdapterRegistry(AdapterRegistry[MemoryProtocol]):
|
||||
"""
|
||||
Thread-safe registry for memory adapters.
|
||||
|
||||
Replaces hardcoded _check_chromadb, _check_mem0, etc. in memory.py
|
||||
with dynamic adapter discovery and instantiation.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Register adapter (typically done on import)
|
||||
register_memory_adapter("sqlite", SQLiteAdapter)
|
||||
register_memory_adapter("chroma", ChromaAdapter) # in wrapper
|
||||
|
||||
# Use in Memory class
|
||||
adapter = get_memory_adapter("chroma", config=config)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(adapter_type_name="Memory")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_memory_registry = MemoryAdapterRegistry()
|
||||
|
||||
|
||||
def register_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter class."""
|
||||
_memory_registry.register_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def register_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter factory function."""
|
||||
_memory_registry.register_factory(name, factory_func)
|
||||
|
||||
|
||||
def get_memory_adapter(name: str, **kwargs) -> Optional[MemoryProtocol]:
|
||||
"""Get memory adapter instance by name."""
|
||||
return _memory_registry.get_adapter(name, **kwargs)
|
||||
|
||||
|
||||
def list_memory_adapters() -> List[str]:
|
||||
"""List all registered memory adapter names."""
|
||||
return _memory_registry.list_adapters()
|
||||
|
||||
|
||||
# Canonical aliases per AGENTS.md naming conventions
|
||||
def add_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
|
||||
"""Canonical alias for register_memory_adapter (preferred naming per AGENTS.md)."""
|
||||
return register_memory_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def add_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
|
||||
"""Canonical alias for register_memory_factory (preferred naming per AGENTS.md)."""
|
||||
return register_memory_factory(name, factory_func)
|
||||
|
||||
|
||||
def has_memory_adapter(name: str) -> bool:
|
||||
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
|
||||
return _memory_registry.is_available(name)
|
||||
|
||||
|
||||
def get_first_available_memory_adapter(
|
||||
preferences: Optional[List[str]] = None, **kwargs
|
||||
) -> Optional[Tuple[str, MemoryProtocol]]:
|
||||
"""
|
||||
Get first available memory adapter.
|
||||
|
||||
Args:
|
||||
preferences: List of adapter names to try (defaults to ["sqlite", "in_memory"])
|
||||
**kwargs: Configuration passed to adapter constructor
|
||||
|
||||
Returns:
|
||||
(adapter_name, adapter_instance) tuple or None
|
||||
"""
|
||||
if preferences is None:
|
||||
preferences = ["sqlite", "in_memory"]
|
||||
|
||||
return _memory_registry.get_first_available(preferences, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'MemoryAdapterRegistry',
|
||||
'register_memory_adapter',
|
||||
'register_memory_factory',
|
||||
'get_memory_adapter',
|
||||
'list_memory_adapters',
|
||||
'get_first_available_memory_adapter',
|
||||
# Canonical aliases (preferred)
|
||||
'add_memory_adapter',
|
||||
'add_memory_factory',
|
||||
'has_memory_adapter',
|
||||
]
|
||||
@@ -8,6 +8,7 @@ and quality scoring logic. Split from the main memory.py file for better maintai
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
@@ -304,4 +305,86 @@ class MemoryCoreMixin:
|
||||
self._learn_manager = LearnManager(config=self._learn_config)
|
||||
except ImportError:
|
||||
logging.warning("Learn manager not available - install learn dependencies")
|
||||
return self._learn_manager
|
||||
return self._learn_manager
|
||||
|
||||
# Async variants to prevent event loop blocking
|
||||
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
|
||||
"""
|
||||
Async version of store_short_term to prevent event loop blocking.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to automatically promote to LTM if quality is high
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
"""
|
||||
if not content.strip():
|
||||
return ""
|
||||
|
||||
# Calculate quality score if not provided
|
||||
if quality_score is None:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Prepare metadata (mirror sync version's metadata construction including sanitization
|
||||
# to ensure only JSON-serializable values are stored, preventing crashes across backends)
|
||||
raw_metadata = metadata.copy() if metadata else {}
|
||||
raw_metadata.update({
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"quality_score": quality_score,
|
||||
"memory_type": "short_term"
|
||||
})
|
||||
if user_id:
|
||||
raw_metadata["user_id"] = user_id
|
||||
clean_metadata = self._sanitize_metadata(raw_metadata)
|
||||
|
||||
# Store in SQLite STM
|
||||
memory_id = ""
|
||||
try:
|
||||
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM: {e}")
|
||||
return ""
|
||||
|
||||
# Auto-promote to long-term memory if quality is high (async)
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
try:
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
|
||||
# Emit memory event
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
|
||||
|
||||
return memory_id or ""
|
||||
|
||||
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Async version of store_long_term to prevent event loop blocking.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
"""
|
||||
if not content.strip():
|
||||
return ""
|
||||
|
||||
# Calculate quality score if not provided
|
||||
if quality_score is None:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Use sync version in thread to avoid blocking event loop
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
|
||||
@@ -26,12 +26,6 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, 'TRACE')
|
||||
|
||||
def trace(self, message, *args, **kwargs):
|
||||
if self.isEnabledFor(TRACE_LEVEL):
|
||||
self._log(TRACE_LEVEL, message, args, **kwargs)
|
||||
|
||||
logging.Logger.trace = trace
|
||||
|
||||
# Thread-safe lazy imports using proper thread synchronization
|
||||
import threading
|
||||
|
||||
@@ -942,7 +936,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
|
||||
logger.info("Getting embeddings...")
|
||||
logger.trace(f"Embedding input text: {text}")
|
||||
logger.log(TRACE_LEVEL, f"Embedding input text: {text}")
|
||||
|
||||
result = get_embedding(text, model=self.embedding_model)
|
||||
embedding = result.embeddings[0] if result.embeddings else None
|
||||
@@ -952,7 +946,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
return
|
||||
|
||||
logger.info("Successfully got embeddings")
|
||||
logger.trace(f"Received embedding of length: {len(embedding)}")
|
||||
logger.log(TRACE_LEVEL, f"Received embedding of length: {len(embedding)}")
|
||||
|
||||
# Sanitize metadata for ChromaDB
|
||||
sanitized_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
@@ -115,8 +115,9 @@ def enable(plugins: list = None) -> None:
|
||||
from .manager import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
|
||||
# Auto-discover plugins from default directories
|
||||
# Auto-discover plugins from default directories and entry points
|
||||
manager.auto_discover_plugins()
|
||||
manager.discover_entry_points()
|
||||
|
||||
# Enable specific plugins or all
|
||||
if plugins is not None:
|
||||
|
||||
@@ -57,23 +57,24 @@ class PluginManager:
|
||||
Returns:
|
||||
True if registered successfully
|
||||
"""
|
||||
try:
|
||||
info = plugin.info
|
||||
if info.name in self._plugins:
|
||||
logger.warning(f"Plugin '{info.name}' already registered")
|
||||
with self._lock:
|
||||
try:
|
||||
info = plugin.info
|
||||
if info.name in self._plugins:
|
||||
logger.warning(f"Plugin '{info.name}' already registered")
|
||||
return False
|
||||
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = True
|
||||
|
||||
# Initialize plugin
|
||||
plugin.on_init({})
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
return False
|
||||
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = True
|
||||
|
||||
# Initialize plugin
|
||||
plugin.on_init({})
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
return False
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -423,6 +424,97 @@ class PluginManager:
|
||||
def get_single_file_plugin(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single-file plugin by name."""
|
||||
return self._single_file_plugins.get(name)
|
||||
|
||||
def discover_entry_points(self) -> int:
|
||||
"""
|
||||
Discover plugins registered via pip entry_points.
|
||||
|
||||
Looks for entry_points in the "praisonaiagents.plugins" group.
|
||||
Compatible with Python 3.8+ using importlib.metadata backport.
|
||||
|
||||
Returns:
|
||||
Number of plugins loaded
|
||||
"""
|
||||
# Lazy import to reduce startup time
|
||||
def _get_entry_points():
|
||||
try:
|
||||
from importlib.metadata import entry_points as _ep
|
||||
return _ep
|
||||
except ImportError:
|
||||
logger.warning("importlib.metadata not available - cannot discover entry_points plugins")
|
||||
return None
|
||||
|
||||
get_ep = _get_entry_points()
|
||||
if get_ep is None:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# Python 3.10+ style - try group= first
|
||||
eps = get_ep(group="praisonaiagents.plugins")
|
||||
except TypeError:
|
||||
# Python 3.9 fallback - call then get group
|
||||
try:
|
||||
all_eps = get_ep()
|
||||
eps = all_eps.get("praisonaiagents.plugins", [])
|
||||
except Exception:
|
||||
eps = []
|
||||
|
||||
loaded = 0
|
||||
# Use lock to ensure thread-safe discovery of multiple entry points
|
||||
with self._lock:
|
||||
for ep in eps:
|
||||
try:
|
||||
plugin_class = ep.load()
|
||||
plugin = plugin_class()
|
||||
# register() also acquires lock but that's fine (RLock allows re-entry)
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
logger.info(f"Loaded entry_point plugin: {ep.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load entry_point plugin {ep.name}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
# =========================================================================
|
||||
# Entry Point Discovery Support (External Packages)
|
||||
# =========================================================================
|
||||
|
||||
def discover_entry_points(self) -> int:
|
||||
"""
|
||||
Auto-discover and load protocol-driven plugins installed via pip
|
||||
that register in the 'praisonai.plugins' entry-point group.
|
||||
|
||||
Returns:
|
||||
Number of plugins loaded successfully.
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
loaded = 0
|
||||
try:
|
||||
# Python 3.10+
|
||||
entry_points = importlib.metadata.entry_points(group="praisonai.plugins")
|
||||
except TypeError:
|
||||
# Python 3.8/3.9 fallback
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points().get("praisonai.plugins", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get entry points: {e}")
|
||||
return 0
|
||||
|
||||
for ep in entry_points:
|
||||
try:
|
||||
plugin_cls = ep.load()
|
||||
# Duck-type check or inheritance check isn't strictly necessary if it conforms to Plugin interface at runtime
|
||||
if callable(plugin_cls):
|
||||
plugin = plugin_cls()
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
else:
|
||||
logger.warning(f"Entry point {ep.name} is not callable")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
# Global plugin manager instance
|
||||
_default_manager: Optional[PluginManager] = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Optional, List, Any, AsyncGenerator
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -25,6 +27,8 @@ class Process:
|
||||
manager_llm: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
max_iter: int = 10,
|
||||
max_retries: int = 3,
|
||||
workflow_timeout: Optional[int] = None, # seconds, None = no timeout
|
||||
output: Optional[str] = None,
|
||||
):
|
||||
logging.debug(f"=== Initializing Process ===")
|
||||
@@ -37,9 +41,11 @@ class Process:
|
||||
self.agents = agents
|
||||
self.manager_llm = manager_llm
|
||||
self.max_iter = max_iter
|
||||
self.max_retries = max_retries
|
||||
self.workflow_timeout = workflow_timeout
|
||||
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
|
||||
self.workflow_finished = False # ADDED: Workflow finished flag
|
||||
self._state_lock = asyncio.Lock() # Async lock for shared state protection
|
||||
self._state_lock = None # Lazy-initialized async lock for shared state protection
|
||||
|
||||
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
|
||||
if output is not None:
|
||||
@@ -233,7 +239,7 @@ class Process:
|
||||
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
|
||||
task.description = task.description.split('Input data from previous tasks:')[0].strip()
|
||||
|
||||
while fallback_attempts < Process.DEFAULT_RETRY_LIMIT and not temp_current_task:
|
||||
while fallback_attempts < self.max_retries and not temp_current_task:
|
||||
fallback_attempts += 1
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Trying to find next 'not started' task.")
|
||||
for task_candidate in self.tasks.values():
|
||||
@@ -248,13 +254,13 @@ class Process:
|
||||
if not leads_to_task and not task_candidate.next_tasks:
|
||||
continue # Skip if no valid path exists
|
||||
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < Process.DEFAULT_RETRY_LIMIT:
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
|
||||
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
if not temp_current_task:
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
|
||||
@@ -383,6 +389,7 @@ class Process:
|
||||
"""Async version of workflow method"""
|
||||
logging.debug("=== Starting Async Workflow ===")
|
||||
current_iter = 0 # Track how many times we've looped
|
||||
workflow_start = time.monotonic() # For timeout enforcement
|
||||
# Build workflow relationships first
|
||||
logging.debug("Building workflow relationships...")
|
||||
for task in self.tasks.values():
|
||||
@@ -418,6 +425,13 @@ class Process:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
|
||||
break
|
||||
|
||||
# Enforce workflow timeout if set
|
||||
if self.workflow_timeout is not None:
|
||||
elapsed = time.monotonic() - workflow_start
|
||||
if elapsed > self.workflow_timeout:
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
|
||||
break
|
||||
|
||||
# ADDED: Check workflow finished flag at the start of each cycle
|
||||
if self.workflow_finished:
|
||||
logging.info("Workflow finished early as all tasks are completed.")
|
||||
@@ -455,11 +469,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Add context from previous tasks to description
|
||||
# Build context and set description for this execution pass only
|
||||
context = self._build_task_context(current_task)
|
||||
if context:
|
||||
# Update task description with context
|
||||
current_task.description = current_task.description + context
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -566,6 +582,9 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
@@ -577,28 +596,31 @@ Subtask: {st.name}
|
||||
current_task = None
|
||||
break
|
||||
|
||||
# Reset completed task to "not started" so it can run again
|
||||
if self.tasks[task_id].status == "completed":
|
||||
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
|
||||
subtask_name = self.tasks[task_id].name
|
||||
task_to_check = self.tasks[task_id]
|
||||
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
|
||||
logging.debug(f"Task type: {task_to_check.task_type}")
|
||||
logging.debug(f"Task status before reset check: {task_to_check.status}")
|
||||
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
|
||||
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
|
||||
# Reset completed task to "not started" so it can run again (atomic operation)
|
||||
if self._state_lock is None:
|
||||
self._state_lock = asyncio.Lock()
|
||||
async with self._state_lock:
|
||||
if self.tasks[task_id].status == "completed":
|
||||
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
|
||||
subtask_name = self.tasks[task_id].name
|
||||
task_to_check = self.tasks[task_id]
|
||||
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
|
||||
logging.debug(f"Task type: {task_to_check.task_type}")
|
||||
logging.debug(f"Task status before reset check: {task_to_check.status}")
|
||||
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
|
||||
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
|
||||
|
||||
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
|
||||
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
|
||||
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()) and
|
||||
not task_to_check.async_execution): # Don't reset async parallel tasks
|
||||
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
|
||||
self.tasks[task_id].status = "not started"
|
||||
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
|
||||
else:
|
||||
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
|
||||
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
|
||||
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()) and
|
||||
not task_to_check.async_execution): # Don't reset async parallel tasks
|
||||
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
|
||||
self.tasks[task_id].status = "not started"
|
||||
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
|
||||
else:
|
||||
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
|
||||
# Handle loop progression
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1116,11 +1138,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Add context from previous tasks to description
|
||||
# Build context and set description for this execution pass only
|
||||
context = self._build_task_context(current_task)
|
||||
if context:
|
||||
# Update task description with context
|
||||
current_task.description = current_task.description + context
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1227,6 +1251,9 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
|
||||
@@ -118,6 +118,67 @@ class RAGProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RetrievalStrategyProtocol(Protocol):
|
||||
"""
|
||||
Protocol for pluggable retrieval strategies.
|
||||
|
||||
Enables custom retrieval algorithms without modifying core RAG pipeline.
|
||||
Implementations can provide different retrieval approaches (semantic,
|
||||
keyword, hybrid, graph-based, etc.) through a unified interface.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
knowledge_store: Any, # KnowledgeStoreProtocol
|
||||
*,
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve relevant documents for query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
knowledge_store: Knowledge store to search
|
||||
limit: Maximum number of results
|
||||
filters: Optional metadata filters
|
||||
**kwargs: Strategy-specific options
|
||||
|
||||
Returns:
|
||||
List of retrieved documents with metadata
|
||||
"""
|
||||
...
|
||||
|
||||
async def aretrieve(
|
||||
self,
|
||||
query: str,
|
||||
knowledge_store: Any, # KnowledgeStoreProtocol
|
||||
*,
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Async version of retrieve.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
knowledge_store: Knowledge store to search
|
||||
limit: Maximum number of results
|
||||
filters: Optional metadata filters
|
||||
**kwargs: Strategy-specific options
|
||||
|
||||
Returns:
|
||||
List of retrieved documents with metadata
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GraphHookProtocol(Protocol):
|
||||
"""
|
||||
|
||||
@@ -640,6 +640,7 @@ class MinimalTelemetry:
|
||||
|
||||
# Global telemetry instance
|
||||
_telemetry_instance = None
|
||||
_telemetry_instance_lock = threading.Lock()
|
||||
|
||||
def get_telemetry() -> MinimalTelemetry:
|
||||
"""
|
||||
@@ -649,17 +650,19 @@ def get_telemetry() -> MinimalTelemetry:
|
||||
The global MinimalTelemetry instance
|
||||
"""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance is None:
|
||||
_telemetry_instance = MinimalTelemetry()
|
||||
return _telemetry_instance
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance is None:
|
||||
_telemetry_instance = MinimalTelemetry()
|
||||
return _telemetry_instance
|
||||
|
||||
def disable_telemetry():
|
||||
"""Programmatically disable telemetry."""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = False
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=False)
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = False
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=False)
|
||||
|
||||
def force_shutdown_telemetry():
|
||||
"""
|
||||
@@ -667,8 +670,9 @@ def force_shutdown_telemetry():
|
||||
This function ensures proper termination of all background threads.
|
||||
"""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.shutdown()
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.shutdown()
|
||||
|
||||
# Additional cleanup - wait for all threads to finish
|
||||
import threading
|
||||
@@ -698,11 +702,12 @@ def force_shutdown_telemetry():
|
||||
def enable_telemetry():
|
||||
"""Programmatically enable telemetry (if not disabled by environment)."""
|
||||
global _telemetry_instance
|
||||
if not _is_telemetry_disabled():
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = True
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=True)
|
||||
with _telemetry_instance_lock:
|
||||
if not _is_telemetry_disabled():
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = True
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=True)
|
||||
|
||||
# For backward compatibility with existing code
|
||||
class TelemetryCollector:
|
||||
|
||||
@@ -200,7 +200,16 @@ TOOL_MAPPINGS = {
|
||||
'email_tools': ('.email_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances (protected by _tools_lock)
|
||||
# Tool factory functions - caches classes but creates fresh instances
|
||||
# This prevents state leakage between concurrent agents while optimizing import performance
|
||||
_loaded_classes = {} # Cache the Class, NOT the instance
|
||||
|
||||
def _create_tool_instance(class_name: str, module_path: str):
|
||||
"""Create a new tool instance. Caches the class but returns fresh instances to prevent state sharing."""
|
||||
if class_name not in _loaded_classes:
|
||||
module = import_module(module_path, __package__)
|
||||
_loaded_classes[class_name] = getattr(module, class_name)
|
||||
return _loaded_classes[class_name]() # Fresh instance safe for multi-agent
|
||||
|
||||
# Profile exports (lazy loaded)
|
||||
_PROFILE_EXPORTS = frozenset({
|
||||
@@ -325,17 +334,10 @@ def __getattr__(name: str) -> Any:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
# Class method import (thread-safe)
|
||||
if class_name not in _instances:
|
||||
with _tools_lock:
|
||||
# Double-check pattern to avoid race conditions
|
||||
if class_name not in _instances:
|
||||
module = import_module(module_path, __package__)
|
||||
class_ = getattr(module, class_name)
|
||||
_instances[class_name] = class_()
|
||||
|
||||
# Get the method and bind it to the instance
|
||||
method = getattr(_instances[class_name], name)
|
||||
# Create a fresh tool instance for each agent/session to prevent state leakage
|
||||
# This factory pattern ensures multi-agent safety by avoiding shared mutable state
|
||||
instance = _create_tool_instance(class_name, module_path)
|
||||
method = getattr(instance, name)
|
||||
return method
|
||||
|
||||
__all__ = list(TOOL_MAPPINGS.keys()) + [
|
||||
|
||||
@@ -434,8 +434,18 @@ class CircuitBreakerRegistry:
|
||||
self._breakers.clear()
|
||||
|
||||
|
||||
# Global registry instance for convenience
|
||||
_global_registry = CircuitBreakerRegistry()
|
||||
# Global registry instance for convenience (lazy initialization)
|
||||
_global_registry = None
|
||||
_global_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_global_registry() -> CircuitBreakerRegistry:
|
||||
"""Get or create the global circuit breaker registry."""
|
||||
global _global_registry
|
||||
with _global_registry_lock:
|
||||
if _global_registry is None:
|
||||
_global_registry = CircuitBreakerRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def get_circuit_breaker(
|
||||
@@ -455,14 +465,14 @@ def get_circuit_breaker(
|
||||
Returns:
|
||||
CircuitBreaker instance
|
||||
"""
|
||||
return _global_registry.get_or_create(name, config, health_check, fallback)
|
||||
return _get_global_registry().get_or_create(name, config, health_check, fallback)
|
||||
|
||||
|
||||
def get_all_circuit_breaker_stats() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get statistics for all circuit breakers in the global registry."""
|
||||
return _global_registry.get_all_stats()
|
||||
return _get_global_registry().get_all_stats()
|
||||
|
||||
|
||||
def reset_all_circuit_breakers() -> None:
|
||||
"""Reset all circuit breakers in the global registry."""
|
||||
_global_registry.reset_all()
|
||||
_get_global_registry().reset_all()
|
||||
@@ -39,17 +39,20 @@ class FileTools:
|
||||
if filepath.startswith('~'):
|
||||
filepath = os.path.expanduser(filepath)
|
||||
|
||||
# Normalize the path
|
||||
normalized = os.path.normpath(filepath)
|
||||
absolute = os.path.abspath(normalized)
|
||||
|
||||
# Check for path traversal attempts (.. after normalization)
|
||||
# Check for path traversal attempts BEFORE normalization
|
||||
# We check the original input for '..' to catch traversal attempts
|
||||
if '..' in normalized:
|
||||
if '..' in filepath:
|
||||
raise ValueError(f"Path traversal detected: {filepath}")
|
||||
|
||||
# Additional check: ensure the resolved path doesn't escape expected boundaries
|
||||
# This is a basic check - in production, you'd want to define allowed directories
|
||||
# Normalize the path and securely resolve symlinks
|
||||
normalized = os.path.normpath(filepath)
|
||||
absolute = os.path.realpath(normalized)
|
||||
|
||||
# Prevent path traversal outside current workspace / allowed directories
|
||||
cwd = os.getcwd()
|
||||
if not absolute.startswith(cwd):
|
||||
raise ValueError(f"Path traversal detected: {filepath} escapes workspace {cwd}")
|
||||
|
||||
return absolute
|
||||
|
||||
@staticmethod
|
||||
@@ -327,7 +330,7 @@ class FileTools:
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
|
||||
# Download file
|
||||
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
|
||||
with httpx.stream("GET", url, timeout=timeout, follow_redirects=False) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
@@ -455,9 +455,14 @@ def get_circuit_breaker_dashboard_data() -> Dict[str, Any]:
|
||||
|
||||
return dashboard_data
|
||||
|
||||
# Global health monitor instance for convenience
|
||||
_global_health_monitor = HealthMonitor()
|
||||
# Global health monitor instance for convenience (lazy initialization)
|
||||
_global_health_monitor = None
|
||||
_global_health_monitor_lock = threading.Lock()
|
||||
|
||||
def get_health_monitor() -> HealthMonitor:
|
||||
"""Get the global health monitor instance."""
|
||||
return _global_health_monitor
|
||||
global _global_health_monitor
|
||||
with _global_health_monitor_lock:
|
||||
if _global_health_monitor is None:
|
||||
_global_health_monitor = HealthMonitor()
|
||||
return _global_health_monitor
|
||||
@@ -143,7 +143,15 @@ def safe_execute():
|
||||
blocked_attrs = {{
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__'
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
}}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
@@ -162,7 +170,9 @@ def safe_execute():
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'):
|
||||
if node.func.id in ('exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir'):
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -24,6 +25,7 @@ logger = get_logger(__name__)
|
||||
# ── lazy singleton store ─────────────────────────────────────────────────────
|
||||
|
||||
_store_instance = None
|
||||
_store_instance_lock = threading.Lock()
|
||||
|
||||
def set_store(store):
|
||||
"""Inject an external schedule store (e.g., config.yaml-backed).
|
||||
@@ -33,7 +35,8 @@ def set_store(store):
|
||||
``config.yaml`` instead of the default ``jobs.json``.
|
||||
"""
|
||||
global _store_instance
|
||||
_store_instance = store
|
||||
with _store_instance_lock:
|
||||
_store_instance = store
|
||||
|
||||
def _get_store():
|
||||
"""Return (or create) the global schedule store.
|
||||
@@ -42,11 +45,12 @@ def _get_store():
|
||||
Automatically migrates any existing ``jobs.json`` data on first use.
|
||||
"""
|
||||
global _store_instance
|
||||
if _store_instance is None:
|
||||
from ..scheduler.config_store import ConfigYamlScheduleStore
|
||||
_store_instance = ConfigYamlScheduleStore()
|
||||
_store_instance.migrate_from_json()
|
||||
return _store_instance
|
||||
with _store_instance_lock:
|
||||
if _store_instance is None:
|
||||
from ..scheduler.config_store import ConfigYamlScheduleStore
|
||||
_store_instance = ConfigYamlScheduleStore()
|
||||
_store_instance.migrate_from_json()
|
||||
return _store_instance
|
||||
|
||||
# ── tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Schema Version: 1.0
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
@@ -388,17 +389,20 @@ class TraceEmitter:
|
||||
|
||||
# Global default emitter (NoOp by default)
|
||||
_default_emitter: Optional[TraceEmitter] = None
|
||||
_default_emitter_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_emitter() -> TraceEmitter:
|
||||
"""Get the default trace emitter."""
|
||||
global _default_emitter
|
||||
if _default_emitter is None:
|
||||
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
|
||||
return _default_emitter
|
||||
with _default_emitter_lock:
|
||||
if _default_emitter is None:
|
||||
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
|
||||
return _default_emitter
|
||||
|
||||
|
||||
def set_default_emitter(emitter: TraceEmitter) -> None:
|
||||
"""Set the default trace emitter."""
|
||||
global _default_emitter
|
||||
_default_emitter = emitter
|
||||
with _default_emitter_lock:
|
||||
_default_emitter = emitter
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Generic Adapter Registry
|
||||
|
||||
Provides a thread-safe, generic registry for protocol-based adapters.
|
||||
Used by memory/adapters/registry.py and knowledge/adapters/registry.py to
|
||||
avoid code duplication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Dict, Generic, List, Optional, Tuple, Type, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdapterRegistry(Generic[T]):
|
||||
"""
|
||||
Thread-safe generic registry for protocol-based adapters.
|
||||
|
||||
Supports two registration modes:
|
||||
- **class**: instantiated on demand via ``register_adapter``
|
||||
- **factory**: called on demand via ``register_factory``
|
||||
|
||||
Factory entries take priority over class entries when both exist for the
|
||||
same name, because factories can apply extra configuration logic.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter_type_name: str = "adapter") -> None:
|
||||
self._adapters: Dict[str, Type[T]] = {}
|
||||
self._factories: Dict[str, Callable[..., T]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._type_name = adapter_type_name
|
||||
|
||||
def register_adapter(self, name: str, adapter_class: Type[T]) -> None:
|
||||
"""Register an adapter class instantiated on demand."""
|
||||
with self._lock:
|
||||
self._adapters[name] = adapter_class
|
||||
|
||||
def register_factory(self, name: str, factory_func: Callable[..., T]) -> None:
|
||||
"""Register a factory function that creates adapter instances."""
|
||||
with self._lock:
|
||||
self._factories[name] = factory_func
|
||||
|
||||
def get_adapter(self, name: str, **kwargs) -> Optional[T]:
|
||||
"""
|
||||
Return an adapter instance for *name*, or ``None`` if unavailable.
|
||||
|
||||
Tries the factory first (if registered), then falls back to direct
|
||||
class instantiation. Lock is only held for lookup, not construction
|
||||
to avoid deadlocks and blocking registry access.
|
||||
"""
|
||||
# Only hold lock to look up the factory/class, not during instantiation
|
||||
with self._lock:
|
||||
factory = self._factories.get(name)
|
||||
adapter_cls = self._adapters.get(name)
|
||||
|
||||
# Try factory first (instantiate outside lock)
|
||||
if factory is not None:
|
||||
try:
|
||||
return factory(**kwargs)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to create {self._type_name} adapter '{name}' via factory. "
|
||||
"Check adapter config and installed extras."
|
||||
) from exc
|
||||
|
||||
# Try direct class instantiation (outside lock)
|
||||
if adapter_cls is not None:
|
||||
try:
|
||||
return adapter_cls(**kwargs)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to instantiate {self._type_name} adapter '{name}'. "
|
||||
"Check adapter constructor args and installed extras."
|
||||
) from exc
|
||||
|
||||
return None
|
||||
|
||||
def list_adapters(self) -> List[str]:
|
||||
"""Return a sorted list of all registered adapter names."""
|
||||
with self._lock:
|
||||
return sorted(set(self._adapters.keys()) | set(self._factories.keys()))
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""Return ``True`` if *name* has a registered adapter or factory."""
|
||||
with self._lock:
|
||||
return name in self._adapters or name in self._factories
|
||||
|
||||
def get_first_available(
|
||||
self, preferences: List[str], **kwargs
|
||||
) -> Optional[Tuple[str, T]]:
|
||||
"""
|
||||
Return the first successfully instantiated adapter from *preferences*.
|
||||
|
||||
Args:
|
||||
preferences: Adapter names tried in order.
|
||||
**kwargs: Passed to the adapter constructor / factory.
|
||||
|
||||
Returns:
|
||||
``(name, instance)`` tuple, or ``None`` if none could be created.
|
||||
"""
|
||||
for name in preferences:
|
||||
instance = self.get_adapter(name, **kwargs)
|
||||
if instance is not None:
|
||||
return name, instance
|
||||
return None
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.112"
|
||||
version = "1.5.117"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -363,3 +363,92 @@ class TestGlobalPluginManager:
|
||||
manager2 = get_plugin_manager()
|
||||
|
||||
assert manager1 is manager2
|
||||
|
||||
|
||||
class TestEntryPointsDiscovery:
|
||||
"""Tests for entry_points plugin discovery."""
|
||||
|
||||
def test_discover_entry_points_no_plugins(self, monkeypatch):
|
||||
"""Test entry_points discovery with no plugins installed."""
|
||||
import importlib.metadata as metadata
|
||||
|
||||
def mock_entry_points(*args, **kwargs):
|
||||
if kwargs.get("group") == "praisonaiagents.plugins":
|
||||
return []
|
||||
return {"praisonaiagents.plugins": []}
|
||||
|
||||
monkeypatch.setattr(metadata, "entry_points", mock_entry_points)
|
||||
manager = PluginManager()
|
||||
|
||||
# Should return 0 when no entry_points exist for our group
|
||||
loaded = manager.discover_entry_points()
|
||||
|
||||
assert loaded == 0
|
||||
|
||||
def test_discover_entry_points_import_error(self, monkeypatch):
|
||||
"""Test graceful handling when importlib.metadata is not available."""
|
||||
import sys
|
||||
|
||||
# Remove importlib.metadata from sys.modules to simulate import failure
|
||||
original_module = sys.modules.pop("importlib.metadata", None)
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "importlib.metadata":
|
||||
raise ImportError("No module named importlib.metadata")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", mock_import)
|
||||
|
||||
try:
|
||||
manager = PluginManager()
|
||||
loaded = manager.discover_entry_points()
|
||||
|
||||
assert loaded == 0
|
||||
finally:
|
||||
# Restore the module if it existed
|
||||
if original_module is not None:
|
||||
sys.modules["importlib.metadata"] = original_module
|
||||
|
||||
def test_discover_entry_points_success(self, monkeypatch):
|
||||
"""Test successful entry_points discovery and plugin loading."""
|
||||
import importlib.metadata as metadata
|
||||
from praisonaiagents.plugins.base import Plugin, PluginInfo
|
||||
|
||||
# Create a mock plugin class
|
||||
class MockPlugin(Plugin):
|
||||
@property
|
||||
def info(self) -> PluginInfo:
|
||||
return PluginInfo(
|
||||
name="test_plugin",
|
||||
version="1.0.0",
|
||||
description="Test plugin"
|
||||
)
|
||||
|
||||
# Create a mock entry point
|
||||
class MockEntryPoint:
|
||||
def __init__(self, name, plugin_class):
|
||||
self.name = name
|
||||
self._plugin_class = plugin_class
|
||||
|
||||
def load(self):
|
||||
return self._plugin_class
|
||||
|
||||
mock_ep = MockEntryPoint("test_plugin", MockPlugin)
|
||||
|
||||
def mock_entry_points(*args, **kwargs):
|
||||
if kwargs.get("group") == "praisonaiagents.plugins":
|
||||
return [mock_ep]
|
||||
return {"praisonaiagents.plugins": [mock_ep]}
|
||||
|
||||
monkeypatch.setattr(metadata, "entry_points", mock_entry_points)
|
||||
|
||||
manager = PluginManager()
|
||||
loaded = manager.discover_entry_points()
|
||||
|
||||
assert loaded == 1
|
||||
assert "test_plugin" in manager._plugins
|
||||
assert manager._plugins["test_plugin"].__class__ == MockPlugin
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
Unit tests for the new protocol-driven architecture components added in issue #1302.
|
||||
|
||||
Tests cover:
|
||||
- AdapterRegistry base class (generic, thread-safe)
|
||||
- MemoryAdapterRegistry
|
||||
- KnowledgeAdapterRegistry
|
||||
- InMemoryAdapter
|
||||
- LLM provider adapters (DefaultAdapter, OllamaAdapter, AnthropicAdapter, GeminiAdapter)
|
||||
- get_provider_adapter() dispatch function
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AdapterRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAdapterRegistry:
|
||||
"""Tests for the generic AdapterRegistry base class."""
|
||||
|
||||
def _make_registry(self):
|
||||
from praisonaiagents.utils.adapter_registry import AdapterRegistry
|
||||
return AdapterRegistry(adapter_type_name="Test")
|
||||
|
||||
def test_register_and_get_adapter(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class DummyAdapter:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
reg.register_adapter("dummy", DummyAdapter)
|
||||
instance = reg.get_adapter("dummy")
|
||||
assert isinstance(instance, DummyAdapter)
|
||||
|
||||
def test_register_and_get_factory(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class FactoryAdapter:
|
||||
def __init__(self, x=0):
|
||||
self.x = x
|
||||
|
||||
reg.register_factory("factory", lambda **kw: FactoryAdapter(x=kw.get("x", 42)))
|
||||
instance = reg.get_adapter("factory", x=99)
|
||||
assert instance.x == 99
|
||||
|
||||
def test_factory_takes_priority_over_class(self):
|
||||
"""Factory should be tried before class when both are registered."""
|
||||
reg = self._make_registry()
|
||||
results = []
|
||||
|
||||
class ClassAdapter:
|
||||
def __init__(self, **kw):
|
||||
results.append("class")
|
||||
|
||||
reg.register_adapter("both", ClassAdapter)
|
||||
reg.register_factory("both", lambda **kw: results.append("factory") or ClassAdapter())
|
||||
|
||||
reg.get_adapter("both")
|
||||
assert results[0] == "factory"
|
||||
|
||||
def test_returns_none_for_unknown_name(self):
|
||||
reg = self._make_registry()
|
||||
assert reg.get_adapter("nonexistent") is None
|
||||
|
||||
def test_returns_none_when_factory_raises(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
def bad_factory(**kw):
|
||||
raise RuntimeError("intentional failure")
|
||||
|
||||
reg.register_factory("bad", bad_factory)
|
||||
assert reg.get_adapter("bad") is None
|
||||
|
||||
def test_returns_none_when_class_raises(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class BadAdapter:
|
||||
def __init__(self, **kw):
|
||||
raise ValueError("intentional failure")
|
||||
|
||||
reg.register_adapter("bad", BadAdapter)
|
||||
assert reg.get_adapter("bad") is None
|
||||
|
||||
def test_list_adapters_sorted(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class A:
|
||||
pass
|
||||
|
||||
reg.register_adapter("b_adapter", A)
|
||||
reg.register_adapter("a_adapter", A)
|
||||
reg.register_factory("c_factory", lambda **kw: A())
|
||||
names = reg.list_adapters()
|
||||
assert names == sorted(names)
|
||||
assert set(names) == {"a_adapter", "b_adapter", "c_factory"}
|
||||
|
||||
def test_is_available(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class A:
|
||||
pass
|
||||
|
||||
assert not reg.is_available("x")
|
||||
reg.register_adapter("x", A)
|
||||
assert reg.is_available("x")
|
||||
|
||||
def test_get_first_available_returns_first_match(self):
|
||||
reg = self._make_registry()
|
||||
|
||||
class A:
|
||||
pass
|
||||
|
||||
class B:
|
||||
pass
|
||||
|
||||
reg.register_adapter("b", B)
|
||||
result = reg.get_first_available(["a", "b"])
|
||||
assert result is not None
|
||||
name, instance = result
|
||||
assert name == "b"
|
||||
assert isinstance(instance, B)
|
||||
|
||||
def test_get_first_available_returns_none_if_all_fail(self):
|
||||
reg = self._make_registry()
|
||||
assert reg.get_first_available(["x", "y"]) is None
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Concurrent registration and reads should not cause data races."""
|
||||
from praisonaiagents.utils.adapter_registry import AdapterRegistry
|
||||
|
||||
reg = AdapterRegistry(adapter_type_name="ThreadTest")
|
||||
errors = []
|
||||
|
||||
class TAdapter:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
def writer():
|
||||
try:
|
||||
for i in range(50):
|
||||
reg.register_adapter(f"t{i}", TAdapter)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for _ in range(50):
|
||||
reg.list_adapters()
|
||||
reg.get_adapter("t0")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer), threading.Thread(target=reader)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Thread safety errors: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryAdapterRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMemoryAdapterRegistry:
|
||||
def test_imports(self):
|
||||
from praisonaiagents.memory.adapters.registry import (
|
||||
MemoryAdapterRegistry,
|
||||
register_memory_adapter,
|
||||
get_memory_adapter,
|
||||
list_memory_adapters,
|
||||
get_first_available_memory_adapter,
|
||||
)
|
||||
|
||||
def test_core_adapters_registered(self):
|
||||
from praisonaiagents.memory.adapters import list_memory_adapters
|
||||
adapters = list_memory_adapters()
|
||||
assert "sqlite" in adapters
|
||||
assert "in_memory" in adapters
|
||||
|
||||
def test_get_in_memory_adapter(self):
|
||||
from praisonaiagents.memory.adapters import get_memory_adapter
|
||||
adapter = get_memory_adapter("in_memory")
|
||||
assert adapter is not None
|
||||
|
||||
def test_get_first_available_defaults(self):
|
||||
from praisonaiagents.memory.adapters import get_first_available_memory_adapter
|
||||
result = get_first_available_memory_adapter()
|
||||
assert result is not None
|
||||
name, instance = result
|
||||
assert name in ("sqlite", "in_memory")
|
||||
|
||||
def test_custom_registry_isolation(self):
|
||||
"""Custom MemoryAdapterRegistry instances are independent."""
|
||||
from praisonaiagents.memory.adapters.registry import MemoryAdapterRegistry
|
||||
|
||||
reg = MemoryAdapterRegistry()
|
||||
|
||||
class MyAdapter:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
reg.register_adapter("custom", MyAdapter)
|
||||
assert reg.is_available("custom")
|
||||
|
||||
# The global registry should NOT see our custom adapter
|
||||
from praisonaiagents.memory.adapters.registry import list_memory_adapters
|
||||
assert "custom" not in list_memory_adapters()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KnowledgeAdapterRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKnowledgeAdapterRegistry:
|
||||
def test_imports(self):
|
||||
from praisonaiagents.knowledge.adapters.registry import (
|
||||
KnowledgeAdapterRegistry,
|
||||
register_knowledge_adapter,
|
||||
get_knowledge_adapter,
|
||||
list_knowledge_adapters,
|
||||
get_first_available_knowledge_adapter,
|
||||
)
|
||||
|
||||
def test_register_and_retrieve(self):
|
||||
from praisonaiagents.knowledge.adapters.registry import (
|
||||
KnowledgeAdapterRegistry,
|
||||
)
|
||||
|
||||
reg = KnowledgeAdapterRegistry()
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
reg.register_adapter("fake", FakeStore)
|
||||
instance = reg.get_adapter("fake")
|
||||
assert isinstance(instance, FakeStore)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InMemoryAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInMemoryAdapter:
|
||||
def _adapter(self):
|
||||
from praisonaiagents.memory.adapters.in_memory_adapter import InMemoryAdapter
|
||||
return InMemoryAdapter()
|
||||
|
||||
def test_store_and_search_short_term(self):
|
||||
a = self._adapter()
|
||||
a.store_short_term("hello world", {"tag": "test"})
|
||||
results = a.search_short_term("hello")
|
||||
assert len(results) == 1
|
||||
assert results[0]["text"] == "hello world"
|
||||
|
||||
def test_store_and_search_long_term(self):
|
||||
a = self._adapter()
|
||||
a.store_long_term("important fact", {"tag": "lm"})
|
||||
results = a.search_long_term("important")
|
||||
assert len(results) == 1
|
||||
assert results[0]["type"] == "long"
|
||||
|
||||
def test_search_respects_limit(self):
|
||||
a = self._adapter()
|
||||
for i in range(10):
|
||||
a.store_short_term(f"item {i}")
|
||||
results = a.search_short_term("item", limit=3)
|
||||
assert len(results) == 3
|
||||
|
||||
def test_short_term_not_in_long_term_search(self):
|
||||
a = self._adapter()
|
||||
a.store_short_term("cross contamination")
|
||||
results = a.search_long_term("cross")
|
||||
assert len(results) == 0
|
||||
|
||||
def test_get_all_memories(self):
|
||||
a = self._adapter()
|
||||
a.store_short_term("s1")
|
||||
a.store_long_term("l1")
|
||||
all_mem = a.get_all_memories()
|
||||
assert len(all_mem) == 2
|
||||
|
||||
def test_case_insensitive_search(self):
|
||||
a = self._adapter()
|
||||
a.store_short_term("Hello World")
|
||||
results = a.search_short_term("hello")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Provider Adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMProviderAdapters:
|
||||
def test_imports(self):
|
||||
from praisonaiagents.llm.adapters import (
|
||||
DefaultAdapter,
|
||||
OllamaAdapter,
|
||||
AnthropicAdapter,
|
||||
GeminiAdapter,
|
||||
get_provider_adapter,
|
||||
)
|
||||
|
||||
def test_default_adapter_implements_protocol(self):
|
||||
from praisonaiagents.llm.adapters import DefaultAdapter
|
||||
from praisonaiagents.llm.protocols import LLMProviderAdapter
|
||||
adapter = DefaultAdapter()
|
||||
assert isinstance(adapter, LLMProviderAdapter)
|
||||
|
||||
def test_ollama_adapter_implements_protocol(self):
|
||||
from praisonaiagents.llm.adapters import OllamaAdapter
|
||||
from praisonaiagents.llm.protocols import LLMProviderAdapter
|
||||
adapter = OllamaAdapter()
|
||||
assert isinstance(adapter, LLMProviderAdapter)
|
||||
|
||||
def test_anthropic_adapter_prompt_caching(self):
|
||||
from praisonaiagents.llm.adapters import AnthropicAdapter
|
||||
adapter = AnthropicAdapter()
|
||||
assert adapter.supports_prompt_caching() is True
|
||||
|
||||
def test_default_adapter_no_prompt_caching(self):
|
||||
from praisonaiagents.llm.adapters import DefaultAdapter
|
||||
adapter = DefaultAdapter()
|
||||
assert adapter.supports_prompt_caching() is False
|
||||
|
||||
def test_ollama_threshold_matches_production(self):
|
||||
"""OllamaAdapter.should_summarize_tools threshold must match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD."""
|
||||
from praisonaiagents.llm.adapters import OllamaAdapter
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
adapter = OllamaAdapter()
|
||||
threshold = LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD
|
||||
# At threshold the adapter must return True
|
||||
assert adapter.should_summarize_tools(threshold) is True
|
||||
# Before threshold it must return False
|
||||
if threshold > 0:
|
||||
assert adapter.should_summarize_tools(threshold - 1) is False
|
||||
|
||||
def test_gemini_adapter_formats_internal_tools(self):
|
||||
"""GeminiAdapter must recognise the actual Gemini internal tool names."""
|
||||
from praisonaiagents.llm.adapters import GeminiAdapter
|
||||
from praisonaiagents.llm.model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
|
||||
adapter = GeminiAdapter()
|
||||
tools = [{"name": name} for name in GEMINI_INTERNAL_TOOLS]
|
||||
tools.append({"name": "regular_tool"})
|
||||
|
||||
formatted = adapter.format_tools(tools)
|
||||
internal = [t for t in formatted if "function" in t]
|
||||
regular = [t for t in formatted if "function" not in t]
|
||||
|
||||
assert len(internal) == len(GEMINI_INTERNAL_TOOLS)
|
||||
assert len(regular) == 1
|
||||
assert regular[0]["name"] == "regular_tool"
|
||||
|
||||
|
||||
class TestGetProviderAdapter:
|
||||
def test_claude_model_returns_anthropic_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, AnthropicAdapter
|
||||
assert isinstance(get_provider_adapter("claude-3.5-sonnet"), AnthropicAdapter)
|
||||
|
||||
def test_anthropic_in_name_returns_anthropic_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, AnthropicAdapter
|
||||
assert isinstance(get_provider_adapter("anthropic/claude-instant"), AnthropicAdapter)
|
||||
|
||||
def test_ollama_prefix_returns_ollama_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, OllamaAdapter
|
||||
assert isinstance(get_provider_adapter("ollama/llama3"), OllamaAdapter)
|
||||
|
||||
def test_ollama_in_name_returns_ollama_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, OllamaAdapter
|
||||
assert isinstance(get_provider_adapter("my-ollama-model"), OllamaAdapter)
|
||||
|
||||
def test_gemini_prefix_returns_gemini_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, GeminiAdapter
|
||||
assert isinstance(get_provider_adapter("gemini-pro"), GeminiAdapter)
|
||||
|
||||
def test_gemini_slash_prefix_returns_gemini_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, GeminiAdapter
|
||||
assert isinstance(get_provider_adapter("gemini/gemini-2.0-flash"), GeminiAdapter)
|
||||
|
||||
def test_unknown_model_returns_default_adapter(self):
|
||||
from praisonaiagents.llm.adapters import get_provider_adapter, DefaultAdapter
|
||||
assert isinstance(get_provider_adapter("gpt-4o"), DefaultAdapter)
|
||||
Generated
+1
-1
@@ -2951,7 +2951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.112"
|
||||
version = "1.5.117"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-08",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 314,
|
||||
"pythonCoreFeatures": 321,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 36,
|
||||
"parityPercentage": 212.4
|
||||
"gapCount": 42,
|
||||
"parityPercentage": 207.8
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -80,6 +80,7 @@
|
||||
"EmbeddingAgent",
|
||||
"EmbeddingConfig",
|
||||
"EmbeddingResult",
|
||||
"ErrorContextProtocol",
|
||||
"EscalationPipeline",
|
||||
"EscalationStage",
|
||||
"EventType",
|
||||
@@ -124,6 +125,7 @@
|
||||
"KNOWLEDGE_PRESETS",
|
||||
"Knowledge",
|
||||
"KnowledgeConfig",
|
||||
"LLMError",
|
||||
"LLMGuardrail",
|
||||
"LLMPluginProtocol",
|
||||
"LearnBackend",
|
||||
@@ -152,6 +154,7 @@
|
||||
"MultiAgentMemoryConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
"MultiAgentPlanningConfig",
|
||||
"NetworkError",
|
||||
"OCRAgent",
|
||||
"OCRConfig",
|
||||
"OUTPUT_PRESETS",
|
||||
@@ -176,6 +179,7 @@
|
||||
"PluginParseError",
|
||||
"PluginProtocol",
|
||||
"PluginsConfig",
|
||||
"PraisonAIError",
|
||||
"PraisonConfig",
|
||||
"PromptExpanderAgent",
|
||||
"Provider",
|
||||
@@ -223,6 +227,7 @@
|
||||
"TemplateConfig",
|
||||
"TodoItem",
|
||||
"TodoList",
|
||||
"ToolExecutionError",
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
@@ -230,6 +235,7 @@
|
||||
"Tools",
|
||||
"TraceSink",
|
||||
"TraceSinkProtocol",
|
||||
"ValidationError",
|
||||
"VideoAgent",
|
||||
"VideoConfig",
|
||||
"VisionAgent",
|
||||
@@ -292,6 +298,7 @@
|
||||
"memory",
|
||||
"obs",
|
||||
"parallel",
|
||||
"parallel_handoffs",
|
||||
"parse_plugin_header",
|
||||
"parse_plugin_header_from_file",
|
||||
"parse_policy_string",
|
||||
@@ -1796,15 +1803,6 @@
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "BudgetExceededError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "CodeAgent",
|
||||
"python": true,
|
||||
@@ -1940,33 +1938,6 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffCycleError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffDepthError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffInputData",
|
||||
"python": true,
|
||||
@@ -1985,15 +1956,6 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffTimeoutError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "ImageAgent",
|
||||
"python": true,
|
||||
@@ -2237,6 +2199,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "parallel_handoffs",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "prompt_with_handoff_instructions",
|
||||
"python": true,
|
||||
@@ -3071,6 +3042,15 @@
|
||||
"status": "DONE",
|
||||
"category": "bots"
|
||||
},
|
||||
{
|
||||
"feature": "BudgetExceededError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "CACHING_PRESETS",
|
||||
"python": true,
|
||||
@@ -3242,6 +3222,15 @@
|
||||
"status": "DONE",
|
||||
"category": "embedding"
|
||||
},
|
||||
{
|
||||
"feature": "ErrorContextProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "EscalationPipeline",
|
||||
"python": true,
|
||||
@@ -3413,6 +3402,42 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffCycleError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffDepthError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffTimeoutError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "Heartbeat",
|
||||
"python": true,
|
||||
@@ -3467,6 +3492,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "LLMError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "LLMPluginProtocol",
|
||||
"python": true,
|
||||
@@ -3647,6 +3681,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "NetworkError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "OUTPUT_PRESETS",
|
||||
"python": true,
|
||||
@@ -3782,6 +3825,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "PraisonAIError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "PraisonConfig",
|
||||
"python": true,
|
||||
@@ -3953,6 +4005,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ToolExecutionError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ToolPluginProtocol",
|
||||
"python": true,
|
||||
@@ -3980,6 +4041,15 @@
|
||||
"status": "DONE",
|
||||
"category": "trace"
|
||||
},
|
||||
{
|
||||
"feature": "ValidationError",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "WEB_PRESETS",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 314 | **Rust Features:** 667 | **Parity:** 90.8%
|
||||
> **Python Features:** 321 | **Rust Features:** 667 | **Parity:** 89.1%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 314 |
|
||||
| Python Core Features | 321 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **29** |
|
||||
| **Actual Gap Count** | **35** |
|
||||
| Language Limitations (N/A) | 6 |
|
||||
| **Parity** | **90.8%** |
|
||||
| **Parity** | **89.1%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -703,10 +703,12 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `BudgetExceededError`
|
||||
- ❌ `ConsoleBackend`
|
||||
- ❌ `DoomLoopDetector`
|
||||
- ❌ `ErrorContextProtocol`
|
||||
- ❌ `EscalationPipeline`
|
||||
- ❌ `EscalationStage`
|
||||
- ❌ `Heartbeat`
|
||||
- ❌ `HeartbeatConfig`
|
||||
- ❌ `LLMError`
|
||||
- ❌ `LearnBackend`
|
||||
- ❌ `LearnConfig`
|
||||
- ❌ `LearnManager`
|
||||
@@ -714,14 +716,18 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `LearnMode`
|
||||
- ❌ `LearnProtocol`
|
||||
- ❌ `LearnScope`
|
||||
- ❌ `NetworkError`
|
||||
- ❌ `ObservabilityEventType`
|
||||
- ❌ `ObservabilityHooks`
|
||||
- ❌ `PraisonAIError`
|
||||
- ❌ `StructuredFormatter`
|
||||
- ❌ `ToolExecutionError`
|
||||
- ❌ `ToolValidationError`
|
||||
- ❌ `configure_structured_logging`
|
||||
- ❌ `get_logger`
|
||||
- ❌ `get_registry`
|
||||
- ❌ `get_tool`
|
||||
- ❌ `parallel_handoffs`
|
||||
- ❌ `register_tool`
|
||||
- ❌ `validate_tool`
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-08",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 314,
|
||||
"pythonCoreFeatures": 321,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 21,
|
||||
"priorityP0": 0,
|
||||
"gapCount": 28,
|
||||
"priorityP0": 1,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 21
|
||||
"priorityP3": 27
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -84,6 +84,7 @@
|
||||
"EmbeddingAgent",
|
||||
"EmbeddingConfig",
|
||||
"EmbeddingResult",
|
||||
"ErrorContextProtocol",
|
||||
"EscalationPipeline",
|
||||
"EscalationStage",
|
||||
"EventType",
|
||||
@@ -128,6 +129,7 @@
|
||||
"KNOWLEDGE_PRESETS",
|
||||
"Knowledge",
|
||||
"KnowledgeConfig",
|
||||
"LLMError",
|
||||
"LLMGuardrail",
|
||||
"LLMPluginProtocol",
|
||||
"LearnBackend",
|
||||
@@ -156,6 +158,7 @@
|
||||
"MultiAgentMemoryConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
"MultiAgentPlanningConfig",
|
||||
"NetworkError",
|
||||
"OCRAgent",
|
||||
"OCRConfig",
|
||||
"OUTPUT_PRESETS",
|
||||
@@ -180,6 +183,7 @@
|
||||
"PluginParseError",
|
||||
"PluginProtocol",
|
||||
"PluginsConfig",
|
||||
"PraisonAIError",
|
||||
"PraisonConfig",
|
||||
"PromptExpanderAgent",
|
||||
"Provider",
|
||||
@@ -227,6 +231,7 @@
|
||||
"TemplateConfig",
|
||||
"TodoItem",
|
||||
"TodoList",
|
||||
"ToolExecutionError",
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
@@ -234,6 +239,7 @@
|
||||
"Tools",
|
||||
"TraceSink",
|
||||
"TraceSinkProtocol",
|
||||
"ValidationError",
|
||||
"VideoAgent",
|
||||
"VideoConfig",
|
||||
"VisionAgent",
|
||||
@@ -296,6 +302,7 @@
|
||||
"memory",
|
||||
"obs",
|
||||
"parallel",
|
||||
"parallel_handoffs",
|
||||
"parse_plugin_header",
|
||||
"parse_plugin_header_from_file",
|
||||
"parse_policy_string",
|
||||
@@ -336,7 +343,6 @@
|
||||
"Agent",
|
||||
"AudioAgent",
|
||||
"AudioConfig",
|
||||
"BudgetExceededError",
|
||||
"CodeAgent",
|
||||
"CodeConfig",
|
||||
"CodeExecutionStep",
|
||||
@@ -351,12 +357,8 @@
|
||||
"FileSearchCall",
|
||||
"Handoff",
|
||||
"HandoffConfig",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffError",
|
||||
"HandoffInputData",
|
||||
"HandoffResult",
|
||||
"HandoffTimeoutError",
|
||||
"ImageAgent",
|
||||
"MCPCall",
|
||||
"OCRAgent",
|
||||
@@ -378,6 +380,7 @@
|
||||
"create_context_agent",
|
||||
"handoff",
|
||||
"handoff_filters",
|
||||
"parallel_handoffs",
|
||||
"prompt_with_handoff_instructions"
|
||||
],
|
||||
"agents": [
|
||||
@@ -567,17 +570,28 @@
|
||||
"Agents",
|
||||
"AsyncLearnProtocol",
|
||||
"AutonomyConfig",
|
||||
"BudgetExceededError",
|
||||
"DoomLoopDetector",
|
||||
"ErrorContextProtocol",
|
||||
"EscalationPipeline",
|
||||
"EscalationStage",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffError",
|
||||
"HandoffTimeoutError",
|
||||
"Heartbeat",
|
||||
"HeartbeatConfig",
|
||||
"LLMError",
|
||||
"LearnManager",
|
||||
"LearnManagerProtocol",
|
||||
"LearnProtocol",
|
||||
"NetworkError",
|
||||
"ObservabilityEventType",
|
||||
"ObservabilityHooks",
|
||||
"PraisonAIError",
|
||||
"StructuredFormatter",
|
||||
"ToolExecutionError",
|
||||
"ValidationError",
|
||||
"config",
|
||||
"configure_structured_logging",
|
||||
"embedding",
|
||||
@@ -2868,15 +2882,6 @@
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "BudgetExceededError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "CodeAgent",
|
||||
"python": true,
|
||||
@@ -3012,33 +3017,6 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffCycleError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffDepthError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffInputData",
|
||||
"python": true,
|
||||
@@ -3057,15 +3035,6 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffTimeoutError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "ImageAgent",
|
||||
"python": true,
|
||||
@@ -3309,6 +3278,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "parallel_handoffs",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "prompt_with_handoff_instructions",
|
||||
"python": true,
|
||||
@@ -4143,6 +4121,15 @@
|
||||
"status": "DONE",
|
||||
"category": "bots"
|
||||
},
|
||||
{
|
||||
"feature": "BudgetExceededError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "CACHING_PRESETS",
|
||||
"python": true,
|
||||
@@ -4314,6 +4301,15 @@
|
||||
"status": "DONE",
|
||||
"category": "embedding"
|
||||
},
|
||||
{
|
||||
"feature": "ErrorContextProtocol",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "EscalationPipeline",
|
||||
"python": true,
|
||||
@@ -4485,6 +4481,42 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffCycleError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffDepthError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HandoffTimeoutError",
|
||||
"python": true,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "Heartbeat",
|
||||
"python": true,
|
||||
@@ -4539,6 +4571,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "LLMError",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "LLMPluginProtocol",
|
||||
"python": true,
|
||||
@@ -4719,6 +4760,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "NetworkError",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "OUTPUT_PRESETS",
|
||||
"python": true,
|
||||
@@ -4854,6 +4904,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "PraisonAIError",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "PraisonConfig",
|
||||
"python": true,
|
||||
@@ -5025,6 +5084,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ToolExecutionError",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ToolPluginProtocol",
|
||||
"python": true,
|
||||
@@ -5052,6 +5120,15 @@
|
||||
"status": "DONE",
|
||||
"category": "trace"
|
||||
},
|
||||
{
|
||||
"feature": "ValidationError",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "WEB_PRESETS",
|
||||
"python": true,
|
||||
|
||||
+23
-16
@@ -1,32 +1,32 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-01
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-08
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 314 |
|
||||
| Python Core Features | 321 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **21** |
|
||||
| P0 (Critical) | 0 |
|
||||
| **Gap Count** | **28** |
|
||||
| P0 (Critical) | 1 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 21 |
|
||||
| P3 (Low) | 27 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
### P0_CoreParity (57 done, 0 todo)
|
||||
### P0_CoreParity (52 done, 1 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `parallel\_handoffs` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Agent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `BaseTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BudgetExceededError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CodeAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CodeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -42,12 +42,8 @@
|
||||
| `FunctionTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Handoff` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `HandoffConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffDepthError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffInputData` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `HandoffResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ImageAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MCPCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OCRAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -151,7 +147,7 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (175 done, 21 todo)
|
||||
### P3_Advanced (180 done, 27 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
@@ -161,19 +157,25 @@
|
||||
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EscalationStage` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Heartbeat` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `LLMError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `LearnBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnMode` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `NetworkError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PraisonAIError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `A2A` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -199,6 +201,7 @@
|
||||
| `BotMessage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `BotUser` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BudgetExceededError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CACHING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CachingConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -233,6 +236,10 @@
|
||||
| `GatewaySessionProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `GuardrailAction` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GuardrailConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffDepthError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HookPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `HooksConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -357,10 +364,10 @@
|
||||
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents`
|
||||
|
||||
<details>
|
||||
<summary><strong>agent</strong> (46 exports)</summary>
|
||||
<summary><strong>agent</strong> (42 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AudioAgent, AudioConfig, BudgetExceededError, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent...
|
||||
from praisonaiagents import Agent, AudioAgent, AudioConfig, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent, DeepResearchResponse...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -519,10 +526,10 @@ from praisonaiagents import obs
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>other</strong> (22 exports)</summary>
|
||||
<summary><strong>other</strong> (33 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, DoomLoopDetector, EscalationPipeline, EscalationStage, Heartbeat, HeartbeatConfig, LearnManager, LearnManagerProtocol...
|
||||
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage, HandoffCycleError, HandoffDepthError...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { AgentFlow, Task, TaskConfig } from './index';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface YAMLWorkflowDefinition {
|
||||
name: string;
|
||||
@@ -35,12 +36,19 @@ export interface ParsedWorkflow {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Whitelist of allowed step keys to prevent injection
|
||||
const ALLOWED_STEP_KEYS = new Set([
|
||||
'type', 'agent', 'tool', 'input', 'output', 'condition',
|
||||
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse YAML string into workflow definition
|
||||
*/
|
||||
export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
|
||||
// Simple YAML parser for workflow definitions
|
||||
// For production, use js-yaml package
|
||||
// SECURITY: If migrating to js-yaml, you MUST use:
|
||||
// yaml.load(content, { schema: yaml.JSON_SCHEMA })
|
||||
// Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution.
|
||||
const lines = yamlContent.split('\n');
|
||||
const result: YAMLWorkflowDefinition = {
|
||||
name: '',
|
||||
@@ -82,7 +90,11 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
|
||||
type: 'agent'
|
||||
};
|
||||
} else if (currentStep) {
|
||||
// Step properties
|
||||
// Step properties — whitelist allowed keys to prevent injection
|
||||
if (!ALLOWED_STEP_KEYS.has(key)) {
|
||||
// Ignore unknown keys — do not allow arbitrary property injection
|
||||
continue;
|
||||
}
|
||||
if (key === 'type') currentStep.type = value as any;
|
||||
else if (key === 'agent') currentStep.agent = value;
|
||||
else if (key === 'tool') currentStep.tool = value;
|
||||
@@ -265,10 +277,40 @@ function parseValue(value: string): any {
|
||||
export async function loadWorkflowFromFile(
|
||||
filePath: string,
|
||||
agents: Record<string, any> = {},
|
||||
tools: Record<string, any> = {}
|
||||
tools: Record<string, any> = {},
|
||||
options: { basePath?: string; maxFileSizeBytes?: number } = {}
|
||||
): Promise<ParsedWorkflow> {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
// SECURITY: Prevent path traversal
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
// Check for '..' as path segments (not just substring)
|
||||
const pathSegments = normalizedPath.split(path.sep);
|
||||
if (pathSegments.includes('..')) {
|
||||
throw new Error('Path traversal detected: ".." path segments are not allowed');
|
||||
}
|
||||
|
||||
let effectivePath: string;
|
||||
// If basePath is specified, ensure resolvedPath stays within it
|
||||
if (options.basePath) {
|
||||
const resolvedBase = path.resolve(options.basePath);
|
||||
const resolvedFile = path.resolve(options.basePath, normalizedPath);
|
||||
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
|
||||
throw new Error(`File path must be within base directory: ${options.basePath}`);
|
||||
}
|
||||
effectivePath = resolvedFile;
|
||||
} else {
|
||||
effectivePath = path.resolve(normalizedPath);
|
||||
}
|
||||
|
||||
// SECURITY: Enforce file size limit (default 1 MB)
|
||||
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
|
||||
const stat = await fs.stat(effectivePath);
|
||||
if (stat.size > maxSize) {
|
||||
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(effectivePath, 'utf-8');
|
||||
const definition = parseYAMLWorkflow(content);
|
||||
return createWorkflowFromYAML(definition, agents, tools);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.112.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.112.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.117.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.117.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -237,7 +237,7 @@ class AgentsGenerator:
|
||||
|
||||
CLI configuration takes precedence over YAML configuration for:
|
||||
- Global config fields (acp, lsp) -> config.config
|
||||
- Agent-level fields (trust, tool_timeout, planning_tools) -> applied to all agents
|
||||
- Agent-level fields (trust, tool_timeout, planning_tools, autonomy, guardrail, approval) -> applied to all agents
|
||||
|
||||
Args:
|
||||
config (dict): The parsed YAML configuration
|
||||
@@ -258,10 +258,27 @@ class AgentsGenerator:
|
||||
config['config']['lsp'] = cli_config['lsp']
|
||||
self.logger.debug(f"CLI override: lsp = {cli_config['lsp']}")
|
||||
|
||||
# Handle agent-level overrides (trust, tool_timeout, planning_tools)
|
||||
agent_level_fields = ['trust', 'tool_timeout', 'planning_tools']
|
||||
# Handle agent-level overrides (trust, tool_timeout, planning_tools, autonomy, guardrail, approval)
|
||||
agent_level_fields = ['trust', 'tool_timeout', 'planning_tools', 'autonomy', 'guardrail', 'approval', 'approve_all_tools', 'approval_timeout']
|
||||
agent_overrides = {k: v for k, v in cli_config.items() if k in agent_level_fields}
|
||||
|
||||
# Map CLI field names to YAML field names
|
||||
field_mappings = {
|
||||
'guardrail': 'guardrails', # CLI uses --guardrail, YAML uses guardrails
|
||||
'trust': 'approval' # --trust maps to approval=True
|
||||
}
|
||||
|
||||
# Apply field mappings and special handling
|
||||
for cli_field in field_mappings:
|
||||
if cli_field in agent_overrides:
|
||||
value = agent_overrides.pop(cli_field)
|
||||
if cli_field == 'trust' and value:
|
||||
# --trust flag maps to approval=True for auto-approval
|
||||
agent_overrides['approval'] = True
|
||||
elif cli_field == 'guardrail':
|
||||
# --guardrail "description" maps to guardrails config
|
||||
agent_overrides['guardrails'] = value
|
||||
|
||||
if agent_overrides:
|
||||
# Apply to all agents in the config
|
||||
roles = config.get('roles', {})
|
||||
@@ -1179,6 +1196,80 @@ class AgentsGenerator:
|
||||
else:
|
||||
agent_planning.pop('planning_tools')
|
||||
|
||||
# Extract YAML configuration for advanced features
|
||||
autonomy_config = details.get('autonomy')
|
||||
guardrails_config = details.get('guardrails')
|
||||
|
||||
# Extract streaming configuration - YAML takes precedence over CLI
|
||||
has_streaming_config = 'streaming' in details
|
||||
has_legacy_stream = 'stream' in details
|
||||
streaming_config = details.get('streaming')
|
||||
stream_enabled = False
|
||||
stream_metrics = False
|
||||
|
||||
if has_streaming_config:
|
||||
if isinstance(streaming_config, bool):
|
||||
stream_enabled = streaming_config
|
||||
elif isinstance(streaming_config, dict):
|
||||
stream_enabled = streaming_config.get('enabled', False)
|
||||
stream_metrics = streaming_config.get('emit_metrics', False)
|
||||
# Future: can add callbacks, etc. from streaming_config
|
||||
elif has_legacy_stream: # Also support direct 'stream: true' format
|
||||
stream_enabled = details.get('stream', False)
|
||||
|
||||
# CLI streaming flags override if YAML doesn't specify
|
||||
cli_config = getattr(self, 'cli_config', {}) or {}
|
||||
if not has_streaming_config and not has_legacy_stream:
|
||||
stream_enabled = cli_config.get('stream', False)
|
||||
stream_metrics = cli_config.get('stream_metrics', False)
|
||||
|
||||
# Reconstruct approval config from potentially scattered settings
|
||||
approval_val = details.get('approval')
|
||||
approve_all = details.get('approve_all_tools')
|
||||
approval_timeout = details.get('approval_timeout')
|
||||
|
||||
approval_config = None
|
||||
if approval_val is not None or approve_all is not None or approval_timeout is not None:
|
||||
if isinstance(approval_val, dict):
|
||||
approval_dict = approval_val
|
||||
else:
|
||||
approval_dict = {'backend': approval_val}
|
||||
|
||||
if approve_all is not None:
|
||||
approval_dict['approve_all_tools'] = approve_all
|
||||
if approval_timeout is not None:
|
||||
approval_dict['approval_timeout'] = approval_timeout
|
||||
|
||||
try:
|
||||
from .cli.features.approval import resolve_approval_config
|
||||
# Map common YAML fields to resolve_approval_config parameters
|
||||
approval_config = resolve_approval_config(
|
||||
backend_name=approval_dict.get('backend') or approval_dict.get('backend_name'),
|
||||
all_tools=approval_dict.get('approve_all_tools') or approval_dict.get('all_tools', False),
|
||||
timeout=approval_dict.get('approval_timeout') or approval_dict.get('timeout')
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback: Create ApprovalConfig directly if resolve_approval_config isn't available
|
||||
try:
|
||||
from praisonaiagents.approval.protocols import ApprovalConfig
|
||||
approval_config = ApprovalConfig(
|
||||
backend=approval_dict.get('backend', None),
|
||||
all_tools=approval_dict.get('approve_all_tools', approval_dict.get('all_tools', False)),
|
||||
timeout=approval_dict.get('approval_timeout', approval_dict.get('timeout', 0))
|
||||
)
|
||||
except ImportError:
|
||||
# Last resort: disable approval for this agent
|
||||
approval_config = None
|
||||
|
||||
# Build output configuration with streaming support
|
||||
output_config = None
|
||||
if stream_enabled:
|
||||
try:
|
||||
from praisonaiagents.config import OutputConfig
|
||||
output_config = OutputConfig(stream=True, metrics=stream_metrics)
|
||||
except ImportError:
|
||||
self.logger.warning("OutputConfig not available, streaming disabled")
|
||||
|
||||
agent = PraisonAgent(
|
||||
name=role_filled,
|
||||
role=role_filled,
|
||||
@@ -1191,6 +1282,10 @@ class AgentsGenerator:
|
||||
reflection=details.get('reflection', False),
|
||||
tool_timeout=agent_tool_timeout,
|
||||
planning=agent_planning,
|
||||
autonomy=autonomy_config,
|
||||
guardrails=guardrails_config,
|
||||
approval=approval_config,
|
||||
output=output_config,
|
||||
)
|
||||
|
||||
if self.agent_callback:
|
||||
|
||||
@@ -112,7 +112,19 @@ class BrowserServer:
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import os
|
||||
|
||||
origin = websocket.headers.get("origin")
|
||||
allowed_origins = os.environ.get("ALLOWED_ORIGINS", "").split(",")
|
||||
if origin:
|
||||
is_allowed = origin.startswith("http://localhost") or origin.startswith("http://127.0.0.1") or origin.startswith("chrome-extension://")
|
||||
if any(origin == allowed.strip() for allowed in allowed_origins if allowed.strip()):
|
||||
is_allowed = True
|
||||
|
||||
if not is_allowed:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# Create connection tracking
|
||||
|
||||
@@ -237,6 +237,7 @@ def register_commands():
|
||||
from .commands.sandbox import app as sandbox_app
|
||||
from .commands.claw import app as claw_app
|
||||
from .commands.flow import app as flow_app
|
||||
from .commands.langfuse import app as langfuse_app
|
||||
|
||||
# Import TUI and queue commands
|
||||
from .features.tui.debug import create_debug_app as create_tui_debug_app
|
||||
@@ -416,6 +417,7 @@ def register_commands():
|
||||
app.add_typer(sandbox_app, name="sandbox", help="Sandbox container management")
|
||||
app.add_typer(claw_app, name="claw", help="🦞 PraisonAI Dashboard (full UI)")
|
||||
app.add_typer(flow_app, name="flow", help="Visual workflow builder (Langflow)")
|
||||
app.add_typer(langfuse_app, name="langfuse", help="🔍 Langfuse observability platform")
|
||||
|
||||
# Register standardise command
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
"""PraisonAI Langfuse — Observability platform for LLM apps.
|
||||
|
||||
Provides `praisonai langfuse` CLI commands to manage local (Docker) and remote
|
||||
Langfuse instances for observability and evaluation of agent workflows.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
app = typer.Typer(name="langfuse", help="🔍 Langfuse observability platform")
|
||||
|
||||
|
||||
def _format_publishers(publishers):
|
||||
"""Format Publishers field for display in status table."""
|
||||
if isinstance(publishers, list) and publishers:
|
||||
# Publishers is array of port mapping objects
|
||||
ports = []
|
||||
for pub in publishers:
|
||||
if isinstance(pub, dict):
|
||||
url = pub.get("URL", "")
|
||||
target_port = pub.get("TargetPort", "")
|
||||
published_port = pub.get("PublishedPort", "")
|
||||
protocol = pub.get("Protocol", "tcp")
|
||||
if published_port and target_port:
|
||||
ports.append(f"{url}:{published_port}->{target_port}/{protocol}")
|
||||
return ", ".join(ports) if ports else ""
|
||||
elif isinstance(publishers, str):
|
||||
return publishers # Fallback for string format
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def langfuse_start(
|
||||
ctx: typer.Context,
|
||||
port: int = typer.Option(3000, "--port", "-p", help="Port to listen on"),
|
||||
host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host to bind to"),
|
||||
detach: bool = typer.Option(True, "--detach/--no-detach", help="Run in background"),
|
||||
data_dir: str = typer.Option(
|
||||
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
|
||||
),
|
||||
):
|
||||
"""Start local Langfuse server (Docker).
|
||||
|
||||
Clones Langfuse repo and starts the full Docker Compose stack
|
||||
with web UI, worker, PostgreSQL, ClickHouse, Redis, and MinIO.
|
||||
|
||||
Examples:
|
||||
praisonai langfuse
|
||||
praisonai langfuse --port 8080
|
||||
praisonai langfuse --no-detach
|
||||
"""
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
console = Console()
|
||||
|
||||
# Check Docker availability
|
||||
try:
|
||||
subprocess.run(["docker", "--version"], capture_output=True, check=True)
|
||||
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
console.print("[red]Docker or Docker Compose is not installed or not running.[/red]")
|
||||
console.print("[yellow]Please install Docker and ensure the daemon is running.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
# Set up data directory
|
||||
if data_dir is None:
|
||||
data_dir = os.path.expanduser("~/.praisonai/langfuse")
|
||||
data_path = Path(data_dir)
|
||||
repo_path = data_path / "langfuse"
|
||||
|
||||
console.print(f"[blue]📁 Data directory: {data_path}[/blue]")
|
||||
|
||||
# Clone Langfuse repo if not present
|
||||
if not repo_path.exists():
|
||||
console.print("[yellow]📥 Cloning Langfuse repository...[/yellow]")
|
||||
data_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
subprocess.run([
|
||||
"git", "clone",
|
||||
"https://github.com/langfuse/langfuse.git",
|
||||
str(repo_path)
|
||||
], check=True, cwd=data_path)
|
||||
console.print("[green]✅ Repository cloned successfully[/green]")
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Failed to clone Langfuse repository[/red]")
|
||||
raise typer.Abort()
|
||||
else:
|
||||
console.print("[green]✅ Langfuse repository already exists[/green]")
|
||||
|
||||
# Check if containers are already running
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"docker", "compose", "ps", "--format", "table"
|
||||
], cwd=repo_path, capture_output=True, text=True)
|
||||
|
||||
if "langfuse-web" in result.stdout and "Up" in result.stdout:
|
||||
console.print("[yellow]⚠️ Langfuse is already running[/yellow]")
|
||||
console.print(f"[blue]🌐 Access UI: http://{host}:{port}[/blue]")
|
||||
return
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Update docker-compose.yml port if different from default
|
||||
if port != 3000:
|
||||
compose_file = repo_path / "docker-compose.yml"
|
||||
if compose_file.exists():
|
||||
content = compose_file.read_text()
|
||||
content = content.replace("3000:3000", f"{port}:3000")
|
||||
compose_file.write_text(content)
|
||||
|
||||
# Start Docker Compose
|
||||
console.print(f"[blue]🚀 Starting Langfuse on {host}:{port}[/blue]")
|
||||
console.print("[dim]This may take a few minutes on first run (downloading images)...[/dim]")
|
||||
|
||||
cmd = ["docker", "compose", "up"]
|
||||
if detach:
|
||||
cmd.append("-d")
|
||||
|
||||
try:
|
||||
if detach:
|
||||
subprocess.run(cmd, cwd=repo_path, check=True)
|
||||
console.print("[green]✅ Langfuse started successfully[/green]")
|
||||
|
||||
# Wait for health check
|
||||
console.print("[yellow]⏳ Waiting for services to be ready...[/yellow]")
|
||||
try:
|
||||
import requests as requests_lib
|
||||
except ImportError:
|
||||
requests_lib = None
|
||||
max_wait = 180 # 3 minutes
|
||||
for i in range(max_wait):
|
||||
try:
|
||||
if requests_lib is None:
|
||||
break
|
||||
response = requests_lib.get(f"http://{host}:{port}", timeout=5)
|
||||
if response.status_code < 500:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
if i % 10 == 0:
|
||||
console.print(f"[dim]Still waiting... ({i}s/{max_wait}s)[/dim]")
|
||||
else:
|
||||
console.print("[yellow]⚠️ Service may still be starting up[/yellow]")
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]🎉 Langfuse is ready![/bold green]")
|
||||
console.print(f"[blue]🌐 Web UI: http://{host}:{port}[/blue]")
|
||||
console.print("[dim]Default login: [email protected] / admin[/dim]")
|
||||
console.print()
|
||||
console.print("Next steps:")
|
||||
console.print("1. Open the web UI and create your first project")
|
||||
console.print("2. Get API keys from Settings > API Keys")
|
||||
console.print(f"3. Run: praisonai langfuse config --public-key pk-... --secret-key sk-...")
|
||||
console.print("4. Test: praisonai langfuse test")
|
||||
|
||||
else:
|
||||
subprocess.run(cmd, cwd=repo_path)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]❌ Failed to start Langfuse: {e}[/red]")
|
||||
console.print("[yellow]Check Docker logs with: docker compose logs[/yellow]")
|
||||
raise typer.Abort()
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]⏹️ Stopping Langfuse...[/yellow]")
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def langfuse_stop(
|
||||
data_dir: str = typer.Option(
|
||||
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
|
||||
),
|
||||
):
|
||||
"""Stop local Langfuse server."""
|
||||
console = Console()
|
||||
|
||||
# Set up data directory
|
||||
if data_dir is None:
|
||||
data_dir = os.path.expanduser("~/.praisonai/langfuse")
|
||||
repo_path = Path(data_dir) / "langfuse"
|
||||
|
||||
if not repo_path.exists():
|
||||
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[yellow]⏹️ Stopping Langfuse...[/yellow]")
|
||||
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "down"], cwd=repo_path, check=True)
|
||||
console.print("[green]✅ Langfuse stopped successfully[/green]")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]❌ Failed to stop Langfuse: {e}[/red]")
|
||||
raise typer.Abort()
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def langfuse_status(
|
||||
data_dir: str = typer.Option(
|
||||
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
|
||||
),
|
||||
):
|
||||
"""Check Langfuse server status."""
|
||||
console = Console()
|
||||
|
||||
# Set up data directory
|
||||
if data_dir is None:
|
||||
data_dir = os.path.expanduser("~/.praisonai/langfuse")
|
||||
repo_path = Path(data_dir) / "langfuse"
|
||||
|
||||
if not repo_path.exists():
|
||||
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
|
||||
console.print("Run 'praisonai langfuse' to set up Langfuse")
|
||||
return
|
||||
|
||||
try:
|
||||
import requests as requests_lib
|
||||
except ImportError:
|
||||
requests_lib = None
|
||||
|
||||
# Check Docker containers
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"docker", "compose", "ps", "--format", "json"
|
||||
], cwd=repo_path, capture_output=True, text=True, check=True)
|
||||
|
||||
if not result.stdout.strip():
|
||||
console.print("[yellow]📊 No Langfuse containers running[/yellow]")
|
||||
return
|
||||
|
||||
# Parse JSON output
|
||||
containers = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
containers.append(json.loads(line))
|
||||
|
||||
# Create status table
|
||||
table = Table(title="Langfuse Services")
|
||||
table.add_column("Service", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Ports", style="blue")
|
||||
table.add_column("Health", style="yellow")
|
||||
|
||||
for container in containers:
|
||||
status_color = "green" if container.get("State") == "running" else "red"
|
||||
health = container.get("Health", "unknown")
|
||||
|
||||
table.add_row(
|
||||
container.get("Service", "unknown"),
|
||||
f"[{status_color}]{container.get('State', 'unknown')}[/{status_color}]",
|
||||
_format_publishers(container.get("Publishers", [])),
|
||||
health
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Test API availability
|
||||
web_running = any(
|
||||
c.get("Service") == "langfuse-web" and c.get("State") == "running"
|
||||
for c in containers
|
||||
)
|
||||
|
||||
if web_running:
|
||||
# Extract port from web service
|
||||
web_container = next(
|
||||
c for c in containers
|
||||
if c.get("Service") == "langfuse-web"
|
||||
)
|
||||
# Extract published port from Publishers array
|
||||
publishers = web_container.get("Publishers", [])
|
||||
port = "3000"
|
||||
if isinstance(publishers, list) and publishers:
|
||||
# Publishers is array of {"URL": "0.0.0.0", "TargetPort": 80, "PublishedPort": 8080, "Protocol": "tcp"}
|
||||
for pub in publishers:
|
||||
if isinstance(pub, dict) and pub.get("TargetPort") == 3000:
|
||||
port = str(pub.get("PublishedPort", 3000))
|
||||
break
|
||||
elif isinstance(publishers, str) and ":" in publishers:
|
||||
# Fallback for older format or edge cases
|
||||
import re
|
||||
match = re.search(r":(\d+)->", publishers)
|
||||
port = match.group(1) if match else "3000"
|
||||
|
||||
try:
|
||||
if requests_lib is not None:
|
||||
response = requests_lib.get(f"http://127.0.0.1:{port}", timeout=5)
|
||||
if response.status_code < 500:
|
||||
console.print(f"[green]🌐 Web UI available: http://127.0.0.1:{port}[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]🌐 Web UI starting: http://127.0.0.1:{port}[/yellow]")
|
||||
else:
|
||||
console.print(f"[blue]🌐 Web UI: http://127.0.0.1:{port}[/blue]")
|
||||
except Exception:
|
||||
console.print(f"[red]🌐 Web UI not responding: http://127.0.0.1:{port}[/red]")
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Failed to check container status[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Error: {e}[/red]")
|
||||
|
||||
|
||||
@app.command("config")
|
||||
def langfuse_config(
|
||||
public_key: str = typer.Option(None, "--public-key", "-pk", help="Langfuse public key"),
|
||||
secret_key: str = typer.Option(None, "--secret-key", "-sk", help="Langfuse secret key"),
|
||||
host: str = typer.Option(None, "--host", help="Langfuse host URL"),
|
||||
show: bool = typer.Option(False, "--show", help="Show current configuration"),
|
||||
):
|
||||
"""Configure Langfuse connection credentials."""
|
||||
console = Console()
|
||||
|
||||
config_dir = Path.home() / ".praisonai"
|
||||
config_file = config_dir / "langfuse.env"
|
||||
|
||||
if show:
|
||||
# Show current configuration
|
||||
if config_file.exists():
|
||||
console.print(f"[blue]📄 Configuration file: {config_file}[/blue]")
|
||||
content = config_file.read_text()
|
||||
|
||||
table = Table(title="Langfuse Configuration")
|
||||
table.add_column("Setting", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
for line in content.strip().split('\n'):
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
# Mask secret key for security
|
||||
if "SECRET" in key:
|
||||
value = value[:8] + "..." if len(value) > 8 else "***"
|
||||
table.add_row(key, value)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Also show env vars
|
||||
env_vars = []
|
||||
for var in ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST", "LANGFUSE_BASE_URL"]:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
if "SECRET" in var:
|
||||
value = value[:8] + "..." if len(value) > 8 else "***"
|
||||
env_vars.append((var, value))
|
||||
|
||||
if env_vars:
|
||||
console.print("\n[blue]🌍 Environment Variables:[/blue]")
|
||||
for var, value in env_vars:
|
||||
console.print(f" {var}={value}")
|
||||
else:
|
||||
console.print("[yellow]⚠️ No configuration file found[/yellow]")
|
||||
|
||||
return
|
||||
|
||||
# Set configuration
|
||||
if not any([public_key, secret_key, host]):
|
||||
console.print("[red]❌ At least one option must be provided[/red]")
|
||||
console.print("Use --show to view current configuration")
|
||||
raise typer.Abort()
|
||||
|
||||
# Read existing config
|
||||
config_data = {}
|
||||
if config_file.exists():
|
||||
content = config_file.read_text()
|
||||
for line in content.strip().split('\n'):
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
config_data[key] = value
|
||||
|
||||
# Update with new values
|
||||
if public_key:
|
||||
config_data["LANGFUSE_PUBLIC_KEY"] = public_key
|
||||
if secret_key:
|
||||
config_data["LANGFUSE_SECRET_KEY"] = secret_key
|
||||
if host:
|
||||
config_data["LANGFUSE_HOST"] = host
|
||||
|
||||
# Write config
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
|
||||
content_lines = [
|
||||
"# Langfuse Configuration",
|
||||
"# Generated by PraisonAI CLI",
|
||||
""
|
||||
]
|
||||
for key, value in config_data.items():
|
||||
content_lines.append(f"{key}={value}")
|
||||
|
||||
config_file.write_text('\n'.join(content_lines) + '\n')
|
||||
|
||||
console.print(f"[green]✅ Configuration saved to {config_file}[/green]")
|
||||
console.print("\n[blue]💡 To use this configuration, either:[/blue]")
|
||||
console.print(f" 1. Source it: source {config_file}")
|
||||
console.print(" 2. Export variables manually")
|
||||
console.print(" 3. Set them in your shell profile")
|
||||
|
||||
|
||||
@app.command("connect")
|
||||
def langfuse_connect(
|
||||
public_key: str = typer.Option(..., "--public-key", "-pk", help="Langfuse public key"),
|
||||
secret_key: str = typer.Option(..., "--secret-key", "-sk", help="Langfuse secret key"),
|
||||
host: str = typer.Option("https://cloud.langfuse.com", "--host", help="Langfuse host URL"),
|
||||
):
|
||||
"""Configure connection to remote Langfuse instance."""
|
||||
return langfuse_config(public_key=public_key, secret_key=secret_key, host=host)
|
||||
|
||||
|
||||
@app.command("test")
|
||||
def langfuse_test():
|
||||
"""Emit a test trace to verify Langfuse connectivity."""
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig
|
||||
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceEmitter
|
||||
import time
|
||||
except ImportError as e:
|
||||
console.print(f"[red]❌ Import error: {e}[/red]")
|
||||
console.print("[yellow]Install with: pip install praisonai[langfuse][/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
# Load configuration from env vars and ~/.praisonai/langfuse.env if present
|
||||
config_file = Path.home() / ".praisonai" / "langfuse.env"
|
||||
if config_file.exists():
|
||||
# Load env file into current process environment
|
||||
with open(config_file, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
console.print(f"[dim]📄 Loaded config from {config_file}[/dim]")
|
||||
|
||||
config = LangfuseSinkConfig()
|
||||
|
||||
if not config.enabled:
|
||||
console.print("[yellow]⚠️ Langfuse tracing is disabled[/yellow]")
|
||||
console.print("Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment variables")
|
||||
return
|
||||
|
||||
console.print("[blue]🧪 Testing Langfuse connectivity...[/blue]")
|
||||
console.print(f"[dim]Host: {config.host}[/dim]")
|
||||
console.print(f"[dim]Public Key: {config.public_key[:8]}...[/dim]")
|
||||
|
||||
try:
|
||||
# Create sink and emitter
|
||||
sink = LangfuseSink(config)
|
||||
emitter = TraceEmitter(sink=sink, enabled=True)
|
||||
|
||||
# Emit test events
|
||||
agent_name = "test-agent"
|
||||
timestamp = time.time()
|
||||
|
||||
console.print("[yellow]📤 Emitting test events...[/yellow]")
|
||||
|
||||
# Agent start
|
||||
emitter.agent_start(agent_name, metadata={"test": True, "cli": "praisonai langfuse test"})
|
||||
|
||||
# Tool call
|
||||
emitter.tool_start("test_tool", {"query": "Hello, Langfuse!"}, agent_name=agent_name)
|
||||
time.sleep(0.1) # Simulate work
|
||||
emitter.tool_end(
|
||||
"test_tool",
|
||||
duration_ms=100,
|
||||
status="ok",
|
||||
result_summary="Test completed successfully",
|
||||
agent_name=agent_name
|
||||
)
|
||||
|
||||
# Output
|
||||
emitter.output("Test trace emitted successfully!", agent_name=agent_name)
|
||||
|
||||
# Agent end
|
||||
emitter.agent_end(agent_name, status="ok")
|
||||
|
||||
# Flush and close
|
||||
emitter.flush()
|
||||
emitter.close()
|
||||
|
||||
console.print("[green]✅ Test trace sent successfully![/green]")
|
||||
console.print(f"[blue]🌐 Check your Langfuse dashboard: {config.host}[/blue]")
|
||||
console.print("[dim]It may take a few seconds for the trace to appear[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Test failed: {e}[/red]")
|
||||
raise typer.Abort()
|
||||
|
||||
|
||||
@app.command("version")
|
||||
def langfuse_version():
|
||||
"""Show Langfuse version information."""
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
import langfuse
|
||||
console.print(f"[bold]Langfuse SDK[/bold]: {langfuse.__version__}")
|
||||
except ImportError:
|
||||
console.print("[yellow]Langfuse SDK not installed[/yellow]")
|
||||
console.print("[blue]Install with: pip install praisonai[langfuse][/blue]")
|
||||
except AttributeError:
|
||||
console.print("[yellow]Langfuse SDK version unknown[/yellow]")
|
||||
|
||||
# Check local server version if running
|
||||
data_dir = os.path.expanduser("~/.praisonai/langfuse")
|
||||
repo_path = Path(data_dir) / "langfuse"
|
||||
|
||||
if repo_path.exists():
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"git", "describe", "--tags", "--abbrev=0"
|
||||
], cwd=repo_path, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()
|
||||
console.print(f"[bold]Local Langfuse[/bold]: {version}")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -396,17 +396,21 @@ class ActionOrchestrator:
|
||||
|
||||
async def _apply_step(self, step: ActionStep) -> Optional[Dict[str, Any]]:
|
||||
"""Apply a single step."""
|
||||
workspace = Path(self.runtime.config.workspace)
|
||||
workspace = Path(self.runtime.config.workspace).resolve()
|
||||
|
||||
if step.action_type == ActionType.FILE_CREATE:
|
||||
target = workspace / step.target
|
||||
target = (workspace / step.target).resolve()
|
||||
if not str(target).startswith(str(workspace)):
|
||||
raise ValueError(f"Path traversal detected: {step.target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = step.params.get("content", "")
|
||||
target.write_text(content)
|
||||
return {"created": str(target), "diff": f"+++ {target}\n+ (new file)"}
|
||||
|
||||
elif step.action_type == ActionType.FILE_EDIT:
|
||||
target = workspace / step.target
|
||||
target = (workspace / step.target).resolve()
|
||||
if not str(target).startswith(str(workspace)):
|
||||
raise ValueError(f"Path traversal detected: {step.target}")
|
||||
if not target.exists():
|
||||
return None
|
||||
|
||||
@@ -420,15 +424,19 @@ class ActionOrchestrator:
|
||||
}
|
||||
|
||||
elif step.action_type == ActionType.FILE_DELETE:
|
||||
target = workspace / step.target
|
||||
target = (workspace / step.target).resolve()
|
||||
if not str(target).startswith(str(workspace)):
|
||||
raise ValueError(f"Path traversal detected: {step.target}")
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
return {"deleted": str(target), "diff": f"--- {target}\n- (deleted)"}
|
||||
return None
|
||||
|
||||
elif step.action_type == ActionType.FILE_RENAME:
|
||||
source = workspace / step.params.get("source", "")
|
||||
dest = workspace / step.params.get("dest", "")
|
||||
source = (workspace / step.params.get("source", "")).resolve()
|
||||
dest = (workspace / step.params.get("dest", "")).resolve()
|
||||
if not str(source).startswith(str(workspace)) or not str(dest).startswith(str(workspace)):
|
||||
raise ValueError(f"Path traversal detected in rename operation")
|
||||
if source.exists():
|
||||
source.rename(dest)
|
||||
return {"renamed": f"{source} -> {dest}"}
|
||||
|
||||
@@ -8,16 +8,84 @@ making the Agent the central orchestrator for all actions.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Dict, List, TYPE_CHECKING
|
||||
from typing import Callable, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .interactive_runtime import InteractiveRuntime
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_filepath(filepath: str, workspace: Optional[str] = None) -> str:
|
||||
"""Validate and sanitize a filepath against injection attacks.
|
||||
|
||||
Raises ValueError if the path is unsafe.
|
||||
"""
|
||||
# Reject null bytes (common injection technique)
|
||||
if '\x00' in filepath:
|
||||
raise ValueError("Null bytes are not allowed in file paths")
|
||||
|
||||
# Reject obvious shell meta-characters in file names
|
||||
if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')):
|
||||
raise ValueError(f"Unsafe characters in filepath: {filepath!r}")
|
||||
|
||||
# Reject absolute paths — files must be relative to workspace
|
||||
if os.path.isabs(filepath):
|
||||
raise ValueError(f"Absolute paths are not allowed: {filepath!r}")
|
||||
|
||||
# Normalize and reject path traversal
|
||||
normalized = os.path.normpath(filepath)
|
||||
if '..' in normalized.split(os.sep):
|
||||
raise ValueError(f"Path traversal detected in: {filepath!r}")
|
||||
|
||||
# If we have a workspace, ensure the resolved path stays within it
|
||||
if workspace:
|
||||
resolved = Path(os.path.realpath(os.path.join(workspace, normalized)))
|
||||
ws_resolved = Path(os.path.realpath(workspace))
|
||||
try:
|
||||
resolved.relative_to(ws_resolved)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Path {filepath!r} resolves outside workspace {workspace!r}"
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _sanitize_command(command: str) -> str:
|
||||
"""Basic command sanitization — reject common injection vectors.
|
||||
|
||||
The command still goes through ACP approval flow, but this prevents
|
||||
the most egregious prompt-injection-to-shell-injection chains.
|
||||
|
||||
Raises ValueError if dangerous patterns are detected.
|
||||
"""
|
||||
if '\x00' in command:
|
||||
raise ValueError("Null bytes are not allowed in commands")
|
||||
|
||||
# Reject command chaining operators that indicate injection
|
||||
DANGEROUS_PATTERNS = [
|
||||
'$(', '`', # Command substitution
|
||||
'&&', '||', # Command chaining
|
||||
'>>', '>', # Output redirection
|
||||
'|', ';', '&', # Pipe and separators
|
||||
'\n', '\r' # Line breaks
|
||||
]
|
||||
for pattern in DANGEROUS_PATTERNS:
|
||||
if pattern in command:
|
||||
raise ValueError(
|
||||
f"Potentially unsafe command pattern detected: {pattern!r} "
|
||||
f"in command: {command!r}. Use separate commands instead."
|
||||
)
|
||||
|
||||
return command
|
||||
|
||||
|
||||
def create_agent_centric_tools(
|
||||
runtime: "InteractiveRuntime",
|
||||
router: "CodeIntelligenceRouter" = None,
|
||||
@@ -77,8 +145,17 @@ def create_agent_centric_tools(
|
||||
JSON string with result including plan status and verification
|
||||
"""
|
||||
async def _create():
|
||||
# Validate filepath
|
||||
try:
|
||||
safe_path = _sanitize_filepath(
|
||||
filepath,
|
||||
workspace=getattr(runtime.config, 'workspace', None)
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
# Build a detailed prompt for the orchestrator
|
||||
prompt = f"create file {filepath}"
|
||||
prompt = f"create file {safe_path}"
|
||||
|
||||
# Create plan
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
@@ -141,7 +218,15 @@ def create_agent_centric_tools(
|
||||
JSON string with result including plan status
|
||||
"""
|
||||
async def _edit():
|
||||
prompt = f"edit file {filepath}"
|
||||
try:
|
||||
safe_path = _sanitize_filepath(
|
||||
filepath,
|
||||
workspace=getattr(runtime.config, 'workspace', None)
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
prompt = f"edit file {safe_path}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
@@ -189,7 +274,15 @@ def create_agent_centric_tools(
|
||||
JSON string with result
|
||||
"""
|
||||
async def _delete():
|
||||
prompt = f"delete file {filepath}"
|
||||
try:
|
||||
safe_path = _sanitize_filepath(
|
||||
filepath,
|
||||
workspace=getattr(runtime.config, 'workspace', None)
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
prompt = f"delete file {safe_path}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
@@ -234,7 +327,12 @@ def create_agent_centric_tools(
|
||||
JSON string with command output
|
||||
"""
|
||||
async def _execute():
|
||||
prompt = f"run command: {command}"
|
||||
try:
|
||||
safe_cmd = _sanitize_command(command)
|
||||
except ValueError as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
prompt = f"run command: {safe_cmd}"
|
||||
|
||||
result = await orchestrator.create_plan(prompt)
|
||||
if not result.success:
|
||||
@@ -414,10 +512,18 @@ def create_agent_centric_tools(
|
||||
if not path.is_absolute():
|
||||
path = Path(runtime.config.workspace) / filepath
|
||||
|
||||
if not path.exists():
|
||||
# SECURITY: Ensure resolved path stays within workspace
|
||||
resolved = path.resolve()
|
||||
ws_resolved = Path(runtime.config.workspace).resolve()
|
||||
try:
|
||||
resolved.relative_to(ws_resolved)
|
||||
except ValueError:
|
||||
return json.dumps({"error": f"Path escapes workspace: {filepath}"})
|
||||
|
||||
if not resolved.exists():
|
||||
return json.dumps({"error": f"File not found: {filepath}"})
|
||||
|
||||
content = path.read_text()
|
||||
content = resolved.read_text()
|
||||
|
||||
# Track in trace if enabled
|
||||
if runtime._trace:
|
||||
@@ -451,7 +557,15 @@ def create_agent_centric_tools(
|
||||
if not path.is_absolute():
|
||||
path = Path(runtime.config.workspace) / directory
|
||||
|
||||
if not path.exists():
|
||||
# SECURITY: Ensure resolved path stays within workspace
|
||||
resolved = path.resolve()
|
||||
ws_resolved = Path(runtime.config.workspace).resolve()
|
||||
try:
|
||||
resolved.relative_to(ws_resolved)
|
||||
except ValueError:
|
||||
return json.dumps({"error": f"Directory escapes workspace: {directory}"})
|
||||
|
||||
if not resolved.exists():
|
||||
return json.dumps({"error": f"Directory not found: {directory}"})
|
||||
|
||||
files = []
|
||||
|
||||
@@ -30,15 +30,24 @@ def resolve_approval_backend(value: Optional[str]) -> Optional[Any]:
|
||||
Raises:
|
||||
ValueError: If the value is not a recognised backend name.
|
||||
"""
|
||||
if value is None or value.lower() == "none":
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, bool):
|
||||
value = "true" if value else "false"
|
||||
|
||||
if str(value).lower() == "none":
|
||||
return None
|
||||
|
||||
name = value.lower().strip()
|
||||
name = str(value).lower().strip()
|
||||
|
||||
if name in ("true", "console"):
|
||||
if name in ("true", "console", "yes", "1"):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
return ConsoleBackend()
|
||||
|
||||
if name in ("false", "no", "0"):
|
||||
return None
|
||||
|
||||
if name == "auto":
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
return AutoApproveBackend()
|
||||
|
||||
@@ -847,8 +847,14 @@ class TemplatesHandler:
|
||||
zip_path = Path(tmpdir) / "repo.zip"
|
||||
urllib.request.urlretrieve(zip_url, zip_path)
|
||||
|
||||
# Extract
|
||||
# Extract safely, preventing Zip Slip
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
import os
|
||||
tmpdir_real = os.path.realpath(tmpdir)
|
||||
for zip_info in zip_ref.infolist():
|
||||
extracted_path = os.path.realpath(os.path.join(tmpdir_real, zip_info.filename))
|
||||
if not extracted_path.startswith(tmpdir_real):
|
||||
raise Exception(f"Attempted Zip Slip vulnerabilities detected: {zip_info.filename}")
|
||||
zip_ref.extractall(tmpdir)
|
||||
|
||||
# Find the template
|
||||
|
||||
@@ -928,6 +928,8 @@ class PraisonAI:
|
||||
parser.add_argument("--web", "--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
|
||||
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
|
||||
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
|
||||
parser.add_argument("--stream", action="store_true", help="Enable real-time streaming for agent responses")
|
||||
parser.add_argument("--stream-metrics", action="store_true", help="Enable streaming with token metrics display")
|
||||
|
||||
# Planning Mode arguments
|
||||
parser.add_argument("--planning", action="store_true", help="Enable planning mode - create plan before execution")
|
||||
@@ -3996,6 +3998,35 @@ Do NOT add any explanations or formatting."""
|
||||
if getattr(self.args, 'lsp', False):
|
||||
cli_config['lsp'] = True
|
||||
|
||||
# Extract new agent-level CLI flags for YAML parity
|
||||
autonomy = getattr(self.args, 'autonomy', None)
|
||||
if autonomy is not None:
|
||||
cli_config['autonomy'] = autonomy
|
||||
|
||||
guardrail = getattr(self.args, 'guardrail', None)
|
||||
if guardrail is not None:
|
||||
cli_config['guardrail'] = guardrail
|
||||
|
||||
approval = getattr(self.args, 'approval', None)
|
||||
if approval is not None:
|
||||
cli_config['approval'] = approval
|
||||
|
||||
approve_all_tools = getattr(self.args, 'approve_all_tools', None)
|
||||
if approve_all_tools is not None:
|
||||
cli_config['approve_all_tools'] = approve_all_tools
|
||||
|
||||
approval_timeout = getattr(self.args, 'approval_timeout', None)
|
||||
if approval_timeout is not None:
|
||||
cli_config['approval_timeout'] = approval_timeout
|
||||
|
||||
# Extract streaming configuration for YAML CLI parity
|
||||
stream = getattr(self.args, 'stream', False)
|
||||
stream_metrics = getattr(self.args, 'stream_metrics', False)
|
||||
if stream or stream_metrics:
|
||||
cli_config['stream'] = stream or stream_metrics
|
||||
if stream_metrics:
|
||||
cli_config['stream_metrics'] = True
|
||||
|
||||
return cli_config
|
||||
|
||||
def handle_direct_prompt(self, prompt):
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.112 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.117 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Provides SSE-based event streaming for agent-to-user communication.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -240,8 +241,36 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
|
||||
except ImportError:
|
||||
raise ImportError("Starlette or FastAPI required for A2U routes")
|
||||
|
||||
def _authenticate_request(request) -> Optional[JSONResponse]:
|
||||
"""Check bearer token auth when A2U_AUTH_TOKEN is configured.
|
||||
|
||||
Returns None if authenticated, or a 401/403 JSONResponse otherwise.
|
||||
"""
|
||||
auth_token = os.environ.get("A2U_AUTH_TOKEN")
|
||||
if not auth_token:
|
||||
# No token configured — auth disabled (development mode)
|
||||
return None
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
{"error": "Authentication required. Set Authorization: Bearer <token>"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
import hmac
|
||||
provided = auth_header[7:] # strip "Bearer "
|
||||
if not hmac.compare_digest(provided, auth_token):
|
||||
return JSONResponse(
|
||||
{"error": "Invalid authentication token"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
async def a2u_info(request):
|
||||
"""GET /a2u/info - Get A2U server info."""
|
||||
auth_error = _authenticate_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
return JSONResponse({
|
||||
"name": "A2U Event Stream",
|
||||
"version": "1.0.0",
|
||||
@@ -258,6 +287,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
|
||||
|
||||
async def a2u_subscribe(request):
|
||||
"""POST /a2u/subscribe - Subscribe to an event stream."""
|
||||
auth_error = _authenticate_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -279,6 +311,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
|
||||
|
||||
async def a2u_unsubscribe(request):
|
||||
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
|
||||
auth_error = _authenticate_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -295,6 +330,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
|
||||
|
||||
async def a2u_events_stream(request):
|
||||
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
|
||||
auth_error = _authenticate_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
stream_name = request.path_params.get("stream_name", "events")
|
||||
|
||||
# Create subscription for this stream
|
||||
@@ -319,6 +357,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
|
||||
|
||||
async def a2u_events_subscription(request):
|
||||
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
|
||||
auth_error = _authenticate_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
subscription_id = request.path_params.get("subscription_id")
|
||||
|
||||
if subscription_id not in bus._subscriptions:
|
||||
|
||||
@@ -371,9 +371,11 @@ class OAuthManager:
|
||||
Returns:
|
||||
True if valid
|
||||
"""
|
||||
import hmac
|
||||
# Check if we have this token stored
|
||||
for stored_token in self._tokens.values():
|
||||
if stored_token.access_token == token:
|
||||
# Use hmac.compare_digest to prevent timing attacks
|
||||
if hmac.compare_digest(stored_token.access_token, token):
|
||||
return not stored_token.is_expired()
|
||||
|
||||
# Token not found — deny by default
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
PraisonAI Observability Integrations.
|
||||
|
||||
Observability adapters for external platforms like Langfuse, WandB, etc.
|
||||
These live in the wrapper layer and implement TraceSinkProtocol from the core SDK.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .langfuse import LangfuseSink, LangfuseSinkConfig
|
||||
|
||||
__all__ = []
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy imports for optional observability dependencies."""
|
||||
if name == "LangfuseSink":
|
||||
from .langfuse import LangfuseSink
|
||||
return LangfuseSink
|
||||
elif name == "LangfuseSinkConfig":
|
||||
from .langfuse import LangfuseSinkConfig
|
||||
return LangfuseSinkConfig
|
||||
else:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Langfuse TraceSinkProtocol Implementation for PraisonAI.
|
||||
|
||||
Provides LangfuseSink adapter that implements TraceSinkProtocol from the core SDK,
|
||||
enabling observability integration with Langfuse for agent traces, tool calls, and errors.
|
||||
|
||||
Architecture:
|
||||
- Core SDK (praisonaiagents): Defines TraceSinkProtocol
|
||||
- Wrapper (praisonai): Implements LangfuseSink adapter (this file)
|
||||
- Pattern: Protocol-driven design per AGENTS.md §4.1
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class LangfuseSinkConfig:
|
||||
"""
|
||||
Configuration for Langfuse trace sink.
|
||||
|
||||
Follows the False=disabled, True=defaults, Config=custom pattern from AGENTS.md §5.2.
|
||||
|
||||
Fields not explicitly provided are filled from environment variables:
|
||||
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST (or LANGFUSE_BASE_URL).
|
||||
"""
|
||||
public_key: str = ""
|
||||
secret_key: str = ""
|
||||
host: str = ""
|
||||
flush_at: int = 20
|
||||
flush_interval: float = 10.0
|
||||
enabled: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Fill missing fields from environment variables."""
|
||||
import os
|
||||
if not self.public_key:
|
||||
self.public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "")
|
||||
if not self.secret_key:
|
||||
self.secret_key = os.getenv("LANGFUSE_SECRET_KEY", "")
|
||||
if not self.host:
|
||||
self.host = os.getenv(
|
||||
"LANGFUSE_HOST",
|
||||
os.getenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com"),
|
||||
)
|
||||
|
||||
|
||||
class LangfuseSink:
|
||||
"""
|
||||
TraceSinkProtocol implementation for Langfuse.
|
||||
|
||||
Maps PraisonAI ActionEvents to Langfuse v3+ observation API:
|
||||
- AGENT_START -> start_observation(as_type="span") for trace and root span
|
||||
- AGENT_END -> observation.update() + observation.end()
|
||||
- TOOL_START -> start_observation(as_type="span") for child span
|
||||
- TOOL_END -> observation.update() + observation.end()
|
||||
- ERROR -> start_observation(as_type="event", level="ERROR")
|
||||
- OUTPUT -> start_observation(as_type="event")
|
||||
|
||||
Thread-safe: langfuse.Langfuse handles its own batching and thread safety.
|
||||
"""
|
||||
|
||||
__slots__ = ("_config", "_client", "_traces", "_spans", "_lock", "_closed")
|
||||
|
||||
def __init__(self, config: Optional[LangfuseSinkConfig] = None):
|
||||
self._config = config or LangfuseSinkConfig()
|
||||
self._client: Optional[Any] = None # Lazy-loaded langfuse.Langfuse
|
||||
self._traces: Dict[str, Any] = {} # agent_name -> trace observation
|
||||
self._spans: Dict[str, Any] = {} # span_key -> span observation
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
|
||||
if self._config.enabled:
|
||||
self._initialize_client()
|
||||
|
||||
def _initialize_client(self) -> None:
|
||||
"""Lazy import and initialize Langfuse client."""
|
||||
try:
|
||||
import langfuse
|
||||
|
||||
# Validate config
|
||||
if not (self._config.public_key and self._config.secret_key):
|
||||
raise ValueError(
|
||||
"Langfuse credentials missing. Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY "
|
||||
"environment variables or pass them to LangfuseSinkConfig."
|
||||
)
|
||||
|
||||
self._client = langfuse.Langfuse(
|
||||
public_key=self._config.public_key,
|
||||
secret_key=self._config.secret_key,
|
||||
host=self._config.host,
|
||||
flush_at=self._config.flush_at,
|
||||
flush_interval=self._config.flush_interval,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Langfuse is not installed. Install with: pip install praisonai[langfuse]"
|
||||
)
|
||||
|
||||
def emit(self, event: ActionEvent) -> None:
|
||||
"""Emit an action event to Langfuse."""
|
||||
if not self._config.enabled or self._closed or not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
self._handle_event(event)
|
||||
except Exception as e:
|
||||
# Don't let observability errors break agent execution
|
||||
print(f"LangfuseSink error: {e}")
|
||||
|
||||
def _handle_event(self, event: ActionEvent) -> None:
|
||||
"""Handle a single ActionEvent (called under lock)."""
|
||||
event_type = event.event_type
|
||||
agent_name = event.agent_name or "unknown-agent"
|
||||
|
||||
if event_type == ActionEventType.AGENT_START.value:
|
||||
self._handle_agent_start(event, agent_name)
|
||||
elif event_type == ActionEventType.AGENT_END.value:
|
||||
self._handle_agent_end(event, agent_name)
|
||||
elif event_type == ActionEventType.TOOL_START.value:
|
||||
self._handle_tool_start(event, agent_name)
|
||||
elif event_type == ActionEventType.TOOL_END.value:
|
||||
self._handle_tool_end(event, agent_name)
|
||||
elif event_type == ActionEventType.ERROR.value:
|
||||
self._handle_error(event, agent_name)
|
||||
elif event_type == ActionEventType.OUTPUT.value:
|
||||
self._handle_output(event, agent_name)
|
||||
|
||||
def _handle_agent_start(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle AGENT_START -> create trace observation and root span."""
|
||||
# Create root span observation (trace is implicit in v3+)
|
||||
trace_name = f"agent-run-{agent_name}"
|
||||
trace_input = event.metadata.get("input") if event.metadata else None
|
||||
|
||||
# Use unique agent key combining agent_id and name for collision safety
|
||||
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
|
||||
|
||||
span = self._client.start_observation(
|
||||
name=trace_name,
|
||||
as_type="span",
|
||||
input=trace_input,
|
||||
start_time=event.timestamp,
|
||||
metadata={
|
||||
"agent_id": event.agent_id,
|
||||
"agent_name": agent_name,
|
||||
"schema_version": event.schema_version,
|
||||
**(event.metadata if event.metadata else {}),
|
||||
}
|
||||
)
|
||||
# Store both trace and span reference with unique key
|
||||
self._traces[agent_key] = span # Root span serves as trace reference
|
||||
self._spans[agent_key] = span
|
||||
|
||||
def _handle_agent_end(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle AGENT_END -> end root span observation."""
|
||||
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
|
||||
span = self._spans.get(agent_key)
|
||||
if span:
|
||||
span.update(
|
||||
end_time=event.timestamp,
|
||||
output=event.metadata.get("output") if event.metadata else None,
|
||||
status_message=event.status or "completed",
|
||||
level="ERROR" if event.status == "error" else "DEFAULT",
|
||||
)
|
||||
span.end()
|
||||
# Clean up
|
||||
self._spans.pop(agent_key, None)
|
||||
self._traces.pop(agent_key, None)
|
||||
|
||||
def _handle_tool_start(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle TOOL_START -> create child span observation."""
|
||||
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
|
||||
parent_span = self._spans.get(agent_key)
|
||||
if not parent_span:
|
||||
return
|
||||
|
||||
tool_name = event.tool_name or "unknown-tool"
|
||||
|
||||
# Generate unique tool invocation key with UUID for collision safety
|
||||
import uuid
|
||||
tool_invocation_id = str(uuid.uuid4())[:8] # Short UUID
|
||||
tool_key = f"{agent_key}:{tool_name}:{tool_invocation_id}"
|
||||
|
||||
tool_span = self._client.start_observation(
|
||||
name=tool_name,
|
||||
as_type="span",
|
||||
start_time=event.timestamp,
|
||||
input=event.tool_args,
|
||||
metadata={
|
||||
"tool_name": tool_name,
|
||||
"parent_agent": agent_name,
|
||||
"invocation_id": tool_invocation_id,
|
||||
**(event.metadata if event.metadata else {}),
|
||||
}
|
||||
)
|
||||
|
||||
# Store with unique tool key
|
||||
self._spans[tool_key] = tool_span
|
||||
|
||||
def _handle_tool_end(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle TOOL_END -> end tool span observation."""
|
||||
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
|
||||
tool_name = event.tool_name or "unknown-tool"
|
||||
|
||||
# Find the most recent matching tool span
|
||||
tool_key = None
|
||||
for key in self._spans:
|
||||
if key.startswith(f"{agent_key}:{tool_name}:") and key != agent_key:
|
||||
tool_key = key
|
||||
|
||||
if not tool_key:
|
||||
return
|
||||
|
||||
tool_span = self._spans.pop(tool_key, None)
|
||||
if tool_span:
|
||||
tool_span.update(
|
||||
end_time=event.timestamp,
|
||||
output=event.tool_result_summary,
|
||||
status_message=event.status or "completed",
|
||||
level="ERROR" if event.status == "error" else "DEFAULT",
|
||||
metadata={
|
||||
"duration_ms": event.duration_ms,
|
||||
**(event.metadata if event.metadata else {}),
|
||||
}
|
||||
)
|
||||
tool_span.end()
|
||||
|
||||
def _handle_error(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle ERROR -> create error event observation."""
|
||||
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
|
||||
|
||||
error_event = self._client.start_observation(
|
||||
name="error",
|
||||
as_type="event",
|
||||
start_time=event.timestamp,
|
||||
level="ERROR",
|
||||
status_message=event.error_message,
|
||||
input=event.tool_args,
|
||||
metadata={
|
||||
"tool_name": event.tool_name,
|
||||
"agent_name": agent_name,
|
||||
**(event.metadata if event.metadata else {}),
|
||||
}
|
||||
)
|
||||
error_event.end()
|
||||
|
||||
def _handle_output(self, event: ActionEvent, agent_name: str) -> None:
|
||||
"""Handle OUTPUT -> create output event observation."""
|
||||
output_event = self._client.start_observation(
|
||||
name="output",
|
||||
as_type="event",
|
||||
start_time=event.timestamp,
|
||||
output=event.tool_result_summary,
|
||||
metadata={
|
||||
"agent_name": agent_name,
|
||||
**(event.metadata or {}),
|
||||
}
|
||||
)
|
||||
output_event.end()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush any buffered events."""
|
||||
if self._client and not self._closed:
|
||||
try:
|
||||
self._client.flush()
|
||||
except Exception as e:
|
||||
print(f"LangfuseSink flush error: {e}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the sink and release resources."""
|
||||
if not self._closed:
|
||||
# Flush before marking closed, so flush() guard passes
|
||||
self.flush()
|
||||
self._closed = True
|
||||
if self._client:
|
||||
try:
|
||||
# Close any remaining spans
|
||||
with self._lock:
|
||||
for span in self._spans.values():
|
||||
try:
|
||||
span.end()
|
||||
except Exception:
|
||||
pass
|
||||
self._spans.clear()
|
||||
self._traces.clear()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -8,6 +8,7 @@ Install: pip install asyncpg
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -63,35 +64,43 @@ class AsyncPostgresConversationStore(ConversationStore):
|
||||
self.pool_size = pool_size
|
||||
self._pool = None
|
||||
self._initialized = False
|
||||
self._init_lock = None
|
||||
self._lock_creation_lock = threading.Lock()
|
||||
|
||||
async def init(self):
|
||||
"""Initialize connection pool and create tables."""
|
||||
if self._initialized:
|
||||
return
|
||||
if self._init_lock is None:
|
||||
with self._lock_creation_lock:
|
||||
if self._init_lock is None: # Double-checked locking
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"asyncpg is required for async PostgreSQL support. "
|
||||
"Install with: pip install asyncpg"
|
||||
)
|
||||
|
||||
if self.url:
|
||||
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
|
||||
else:
|
||||
self._pool = await asyncpg.create_pool(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
min_size=1,
|
||||
max_size=self.pool_size
|
||||
)
|
||||
|
||||
await self._create_tables()
|
||||
self._initialized = True
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"asyncpg is required for async PostgreSQL support. "
|
||||
"Install with: pip install asyncpg"
|
||||
) from err
|
||||
|
||||
if self.url:
|
||||
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
|
||||
else:
|
||||
self._pool = await asyncpg.create_pool(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
min_size=1,
|
||||
max_size=self.pool_size
|
||||
)
|
||||
|
||||
await self._create_tables()
|
||||
self._initialized = True
|
||||
|
||||
async def _create_tables(self):
|
||||
"""Create required tables if they don't exist."""
|
||||
|
||||
@@ -97,8 +97,9 @@ class MemoryStateStore(StateStore):
|
||||
def _maybe_save(self) -> None:
|
||||
"""Save if auto_save is enabled and interval has passed."""
|
||||
if self.auto_save and self.path:
|
||||
if time.time() - self._last_save >= self.save_interval:
|
||||
self._save()
|
||||
with self._lock:
|
||||
if time.time() - self._last_save >= self.save_interval:
|
||||
self._save()
|
||||
|
||||
def _check_ttl(self, key: str) -> bool:
|
||||
"""Check if key is expired. Returns True if valid, False if expired."""
|
||||
|
||||
@@ -128,6 +128,40 @@ def _validate_version(version: str) -> bool:
|
||||
return bool(re.match(r'^\d+\.\d+\.\d+([a-zA-Z0-9._-]*)?$', version))
|
||||
|
||||
|
||||
def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:
|
||||
"""Safely extract tar members, rejecting path traversal attempts.
|
||||
|
||||
Validates every member name to prevent:
|
||||
- Absolute paths
|
||||
- '..' traversal sequences
|
||||
- Resolved paths that escape dest_dir
|
||||
|
||||
Raises:
|
||||
RegistryError: If any member attempts path traversal.
|
||||
"""
|
||||
dest_resolved = dest_dir.resolve()
|
||||
for member in tar.getmembers():
|
||||
member_path = Path(member.name)
|
||||
# Reject absolute paths
|
||||
if member_path.is_absolute():
|
||||
raise RegistryError(
|
||||
f"Refusing to extract absolute path in archive: {member.name}"
|
||||
)
|
||||
# Reject '..' components
|
||||
if '..' in member_path.parts:
|
||||
raise RegistryError(
|
||||
f"Refusing to extract path traversal in archive: {member.name}"
|
||||
)
|
||||
# Reject resolved paths escaping dest_dir
|
||||
resolved = (dest_resolved / member_path).resolve()
|
||||
if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:
|
||||
raise RegistryError(
|
||||
f"Refusing to extract path escaping target directory: {member.name}"
|
||||
)
|
||||
# All members validated — safe to extract
|
||||
tar.extractall(dest_dir)
|
||||
|
||||
|
||||
def _atomic_write(file_path: Path, data: bytes) -> None:
|
||||
"""Write data atomically using temp file + rename."""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -231,8 +265,27 @@ class LocalRegistry:
|
||||
if not name or not version:
|
||||
raise RegistryError("Bundle manifest missing name or version")
|
||||
|
||||
# Check if version exists
|
||||
# Validate name and version BEFORE any filesystem operation
|
||||
if not _validate_name(name):
|
||||
raise RegistryError(
|
||||
f"Invalid recipe name in manifest: {name!r}. "
|
||||
"Names must be alphanumeric with optional dots, hyphens, or underscores."
|
||||
)
|
||||
if not _validate_version(version):
|
||||
raise RegistryError(
|
||||
f"Invalid version in manifest: {version!r}. "
|
||||
"Versions must follow semver format (e.g. 1.0.0)."
|
||||
)
|
||||
|
||||
# Path confinement: ensure resolved path stays under recipes_path
|
||||
recipe_dir = self.recipes_path / name / version
|
||||
recipes_root = self.recipes_path.resolve()
|
||||
if not str(recipe_dir.resolve()).startswith(str(recipes_root) + os.sep):
|
||||
raise RegistryError(
|
||||
f"Path traversal detected in manifest name/version: {name}@{version}"
|
||||
)
|
||||
|
||||
# Check if version exists
|
||||
if recipe_dir.exists() and not force:
|
||||
raise RecipeExistsError(f"Recipe {name}@{version} already exists. Use --force to overwrite.")
|
||||
|
||||
@@ -341,7 +394,7 @@ class LocalRegistry:
|
||||
recipe_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
tar.extractall(recipe_dir)
|
||||
_safe_extractall(tar, recipe_dir)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
@@ -736,7 +789,7 @@ class HttpRegistry:
|
||||
recipe_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
tar.extractall(recipe_dir)
|
||||
_safe_extractall(tar, recipe_dir)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
|
||||
@@ -41,6 +41,8 @@ from .registry import (
|
||||
DEFAULT_REGISTRY_PORT,
|
||||
_calculate_checksum,
|
||||
_get_timestamp,
|
||||
_validate_name,
|
||||
_validate_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -384,6 +386,20 @@ class RegistryServer:
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
# Validate URL path params before any processing
|
||||
if not _validate_name(name):
|
||||
return self._error_response(
|
||||
f"Invalid recipe name in URL: {name!r}",
|
||||
status=400,
|
||||
code="invalid_name",
|
||||
)
|
||||
if not _validate_version(version):
|
||||
return self._error_response(
|
||||
f"Invalid version in URL: {version!r}",
|
||||
status=400,
|
||||
code="invalid_version",
|
||||
)
|
||||
|
||||
# Parse multipart data
|
||||
content_type = headers.get("Content-Type", "")
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
|
||||
@@ -142,7 +142,11 @@ class AICoder:
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.cwd
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return "Error: Command execution timed out after 60 seconds."
|
||||
if stdout:
|
||||
return f"Command output:\n{stdout.decode()}"
|
||||
if stderr:
|
||||
|
||||
@@ -77,9 +77,12 @@ class SQLAlchemyDataLayer(BaseDataLayer):
|
||||
|
||||
###### SQL Helpers ######
|
||||
async def execute_sql(
|
||||
self, query: str, parameters: dict
|
||||
self, query: str, parameters: dict, expand: Optional[str] = None
|
||||
) -> Union[List[Dict[str, Any]], int, None]:
|
||||
from sqlalchemy import bindparam
|
||||
parameterized_query = text(query)
|
||||
if expand:
|
||||
parameterized_query = parameterized_query.bindparams(bindparam(expand, expanding=True))
|
||||
async with self.async_session() as session:
|
||||
try:
|
||||
await session.begin()
|
||||
@@ -544,12 +547,11 @@ class SQLAlchemyDataLayer(BaseDataLayer):
|
||||
if not user_threads:
|
||||
return []
|
||||
else:
|
||||
# Build parameterized IN clause to prevent SQL injection
|
||||
# Build parameterized IN clause using expanding to prevent SQL injection
|
||||
thread_id_list = [t["thread_id"] for t in user_threads]
|
||||
tid_placeholders = ", ".join(f":tid_{i}" for i in range(len(thread_id_list)))
|
||||
tid_params = {f"tid_{i}": tid for i, tid in enumerate(thread_id_list)}
|
||||
tid_params = {"thread_ids": thread_id_list}
|
||||
|
||||
steps_feedbacks_query = f"""
|
||||
steps_feedbacks_query = """
|
||||
SELECT
|
||||
s."id" AS step_id,
|
||||
s."name" AS step_name,
|
||||
@@ -574,14 +576,14 @@ class SQLAlchemyDataLayer(BaseDataLayer):
|
||||
f."comment" AS feedback_comment,
|
||||
f."id" AS feedback_id
|
||||
FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId"
|
||||
WHERE s."threadId" IN ({tid_placeholders})
|
||||
WHERE s."threadId" IN :thread_ids
|
||||
ORDER BY s."createdAt" ASC
|
||||
"""
|
||||
steps_feedbacks = await self.execute_sql(
|
||||
query=steps_feedbacks_query, parameters=tid_params
|
||||
query=steps_feedbacks_query, parameters=tid_params, expand="thread_ids"
|
||||
)
|
||||
|
||||
elements_query = f"""
|
||||
elements_query = """
|
||||
SELECT
|
||||
e."id" AS element_id,
|
||||
e."threadId" as element_threadid,
|
||||
@@ -597,9 +599,11 @@ class SQLAlchemyDataLayer(BaseDataLayer):
|
||||
e."forId" AS element_forid,
|
||||
e."mime" AS element_mime
|
||||
FROM elements e
|
||||
WHERE e."threadId" IN ({tid_placeholders})
|
||||
WHERE e."threadId" IN :thread_ids
|
||||
"""
|
||||
elements = await self.execute_sql(query=elements_query, parameters=tid_params)
|
||||
elements = await self.execute_sql(
|
||||
query=elements_query, parameters=tid_params, expand="thread_ids"
|
||||
)
|
||||
|
||||
thread_dicts = {}
|
||||
for thread in user_threads:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.5.112"
|
||||
__version__ = "4.5.117"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.112",
|
||||
"praisonaiagents>=1.5.117",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"PyYAML>=6.0",
|
||||
@@ -44,6 +44,7 @@ os = [
|
||||
"sse-starlette>=1.6.0"
|
||||
]
|
||||
agentops = ["agentops>=0.3.12"]
|
||||
langfuse = ["langfuse>=3.0.0"]
|
||||
chat = [
|
||||
"chainlit>=2.8.5,<=2.9.4",
|
||||
"aiosqlite>=0.20.0",
|
||||
@@ -125,6 +126,8 @@ all = [
|
||||
"yfinance>=0.2.44",
|
||||
"ddgs>=9.0.0",
|
||||
"psutil>=5.9.0", # Shell tools
|
||||
# Observability
|
||||
"langfuse>=3.0.0",
|
||||
# Storage backends
|
||||
"redis>=5.0.0",
|
||||
"pymongo>=4.6.0",
|
||||
@@ -183,6 +186,8 @@ claw = [
|
||||
"ddgs>=9.0.0",
|
||||
# Shell tools
|
||||
"psutil>=5.9.0",
|
||||
# Observability
|
||||
"langfuse>=3.0.0",
|
||||
# Bot channels (Telegram, Discord, Slack)
|
||||
"python-telegram-bot>=20.0",
|
||||
"discord.py>=2.0.0",
|
||||
|
||||
@@ -67,16 +67,20 @@ def calculate(expression: str) -> str:
|
||||
'''
|
||||
|
||||
# Write the test files
|
||||
with open("test_agents.yaml", "w") as f:
|
||||
import os
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
os.makedirs(FIXTURES_DIR, exist_ok=True)
|
||||
|
||||
with open(os.path.join(FIXTURES_DIR, "test_agents.yaml"), "w") as f:
|
||||
f.write(agents_yaml_content)
|
||||
|
||||
with open("test_tools.py", "w") as f:
|
||||
with open(os.path.join(FIXTURES_DIR, "test_tools.py"), "w") as f:
|
||||
f.write(tools_py_content)
|
||||
|
||||
print("Test files created successfully!")
|
||||
print("Test files created successfully in tests/fixtures/!")
|
||||
print("\nTo test the fix:")
|
||||
print("1. Copy test_agents.yaml to agents.yaml")
|
||||
print("2. Copy test_tools.py to tools.py")
|
||||
print("1. Copy tests/fixtures/test_agents.yaml to agents.yaml")
|
||||
print("2. Copy tests/fixtures/test_tools.py to tools.py")
|
||||
print("3. Run the chatbot UI")
|
||||
print("4. Ask the agent to use multiple tools")
|
||||
print("\nThe agent should now be able to use all three tools (search_web, get_weather, calculate)")
|
||||
|
||||
@@ -121,9 +121,9 @@ class TestApiMdGenerator:
|
||||
agent_info = generator.agents_symbols["Agent"]
|
||||
assert len(agent_info.methods) > 0, "Agent class should have methods"
|
||||
|
||||
# Check for common methods
|
||||
# Check for common methods that are directly in Agent class (not in mixins)
|
||||
method_names = [m.name for m in agent_info.methods]
|
||||
assert "start" in method_names or "run" in method_names or "chat" in method_names
|
||||
assert "__init__" in method_names or "agent_id" in method_names or "analyze_prompt" in method_names
|
||||
|
||||
|
||||
class TestTypescriptDiscovery:
|
||||
|
||||
@@ -121,9 +121,12 @@ class TestEnhancedYAMLGeneration:
|
||||
from praisonai.auto import AutoGenerator
|
||||
|
||||
# Test that the generator has tool discovery capability
|
||||
import os
|
||||
fixture_path = os.path.join(os.path.dirname(__file__), "..", "fixtures", "test_agents.yaml")
|
||||
|
||||
generator = AutoGenerator(
|
||||
topic="Search the web for AI news and write a summary",
|
||||
agent_file="test_agents.yaml",
|
||||
agent_file=fixture_path,
|
||||
framework="praisonai"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Unit tests for LangfuseSink observability adapter.
|
||||
|
||||
Tests verify:
|
||||
1. LangfuseSinkConfig picks up env vars correctly
|
||||
2. LangfuseSink raises ValueError (not ImportError) for missing credentials
|
||||
3. LangfuseSink silently handles events when disabled
|
||||
4. LangfuseSink correctly routes events to Langfuse API (via mocks)
|
||||
5. flush() / close() lifecycle is handled correctly
|
||||
6. Sink does not let errors propagate to calling code
|
||||
7. Protocol compliance with TraceSinkProtocol
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol
|
||||
from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_event(event_type, agent_name="agent1", **kwargs):
|
||||
"""Create an ActionEvent for testing."""
|
||||
return ActionEvent(
|
||||
event_type=event_type,
|
||||
timestamp=time.time(),
|
||||
agent_name=agent_name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_sink_with_mock_client():
|
||||
"""Return a sink with a mock Langfuse client already injected."""
|
||||
cfg = LangfuseSinkConfig(enabled=False)
|
||||
sink = LangfuseSink(cfg)
|
||||
sink._client = MagicMock()
|
||||
sink._config.enabled = True
|
||||
return sink
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangfuseSinkConfig tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLangfuseSinkConfig:
|
||||
def test_defaults_when_env_empty(self, monkeypatch):
|
||||
"""Config falls back to the default cloud host when env vars not set."""
|
||||
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGFUSE_HOST", raising=False)
|
||||
monkeypatch.delenv("LANGFUSE_BASE_URL", raising=False)
|
||||
|
||||
cfg = LangfuseSinkConfig(enabled=False)
|
||||
assert cfg.public_key == ""
|
||||
assert cfg.secret_key == ""
|
||||
assert cfg.host == "https://cloud.langfuse.com"
|
||||
|
||||
def test_reads_env_vars(self, monkeypatch):
|
||||
"""Config reads credentials from env vars."""
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
|
||||
monkeypatch.setenv("LANGFUSE_HOST", "http://localhost:3000")
|
||||
|
||||
cfg = LangfuseSinkConfig()
|
||||
assert cfg.public_key == "pk-test"
|
||||
assert cfg.secret_key == "sk-test"
|
||||
assert cfg.host == "http://localhost:3000"
|
||||
|
||||
def test_explicit_values_take_precedence(self, monkeypatch):
|
||||
"""Explicitly provided values override env vars."""
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-env")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-env")
|
||||
|
||||
cfg = LangfuseSinkConfig(public_key="pk-explicit", secret_key="sk-explicit")
|
||||
assert cfg.public_key == "pk-explicit"
|
||||
assert cfg.secret_key == "sk-explicit"
|
||||
|
||||
def test_enabled_false_with_no_credentials(self, monkeypatch):
|
||||
"""Disabled config doesn't need credentials."""
|
||||
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
|
||||
|
||||
cfg = LangfuseSinkConfig(enabled=False)
|
||||
assert cfg.enabled is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangfuseSink initialization tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLangfuseSinkInit:
|
||||
def test_disabled_sink_does_not_initialize_client(self):
|
||||
"""Disabled sink should not attempt to import langfuse."""
|
||||
cfg = LangfuseSinkConfig(enabled=False)
|
||||
sink = LangfuseSink(cfg)
|
||||
assert sink._client is None
|
||||
assert sink._closed is False
|
||||
|
||||
def test_missing_credentials_raises_value_error(self, monkeypatch):
|
||||
"""ValueError (not ImportError) raised when credentials missing."""
|
||||
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
|
||||
|
||||
cfg = LangfuseSinkConfig(public_key="", secret_key="", enabled=True)
|
||||
with patch.dict("sys.modules", {"langfuse": MagicMock()}):
|
||||
with pytest.raises(ValueError, match="credentials missing"):
|
||||
LangfuseSink(cfg)
|
||||
|
||||
def test_initialized_with_valid_credentials(self, monkeypatch):
|
||||
"""Sink initializes successfully with valid credentials."""
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
|
||||
|
||||
mock_langfuse_module = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_langfuse_module.Langfuse.return_value = mock_client
|
||||
|
||||
cfg = LangfuseSinkConfig()
|
||||
with patch.dict("sys.modules", {"langfuse": mock_langfuse_module}):
|
||||
sink = LangfuseSink(cfg)
|
||||
|
||||
assert sink._client is mock_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangfuseSink event emission tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLangfuseSinkEmit:
|
||||
def test_emit_agent_start_creates_trace_and_span(self):
|
||||
"""AGENT_START event creates a trace and root span."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
mock_span = MagicMock()
|
||||
sink._client.start_observation.return_value = mock_span
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_START.value)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.start_observation.assert_called_once()
|
||||
call_kwargs = sink._client.start_observation.call_args.kwargs
|
||||
assert call_kwargs.get("as_type") == "span"
|
||||
assert "agent1-agent1" in sink._traces
|
||||
assert "agent1-agent1" in sink._spans
|
||||
|
||||
def test_emit_agent_end_ends_span(self):
|
||||
"""AGENT_END event ends the root span."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
mock_span = MagicMock()
|
||||
sink._spans["agent1-agent1"] = mock_span
|
||||
sink._traces["agent1-agent1"] = MagicMock()
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_END.value, status="ok")
|
||||
sink.emit(event)
|
||||
|
||||
mock_span.end.assert_called_once()
|
||||
assert "agent1-agent1" not in sink._spans
|
||||
assert "agent1-agent1" not in sink._traces
|
||||
|
||||
def test_emit_tool_start_creates_child_span(self):
|
||||
"""TOOL_START creates a child span under the parent agent span."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
mock_parent_span = MagicMock()
|
||||
mock_tool_span = MagicMock()
|
||||
sink._client.start_observation.return_value = mock_tool_span
|
||||
sink._spans["agent1-agent1"] = mock_parent_span
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.TOOL_START.value,
|
||||
tool_name="search_tool",
|
||||
tool_args={"query": "test"},
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.start_observation.assert_called_once()
|
||||
call_kwargs = sink._client.start_observation.call_args.kwargs
|
||||
assert call_kwargs.get("name") == "search_tool"
|
||||
|
||||
tool_keys = [k for k in sink._spans if k.startswith("agent1-agent1:search_tool:")]
|
||||
assert len(tool_keys) == 1
|
||||
|
||||
def test_emit_tool_end_closes_tool_span(self):
|
||||
"""TOOL_END ends the matching tool span."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
mock_tool_span = MagicMock()
|
||||
ts = str(time.time())
|
||||
sink._spans[f"agent1-agent1:search_tool:{ts}"] = mock_tool_span
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.TOOL_END.value,
|
||||
tool_name="search_tool",
|
||||
duration_ms=100.0,
|
||||
status="ok",
|
||||
tool_result_summary="result",
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
mock_tool_span.end.assert_called_once()
|
||||
|
||||
def test_emit_error_creates_error_event(self):
|
||||
"""ERROR event creates a Langfuse event with level=ERROR."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.ERROR.value,
|
||||
error_message="Something went wrong",
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.start_observation.assert_called_once()
|
||||
call_kwargs = sink._client.start_observation.call_args.kwargs
|
||||
assert call_kwargs.get("as_type") == "event"
|
||||
assert call_kwargs.get("level") == "ERROR"
|
||||
|
||||
def test_emit_output_creates_output_event(self):
|
||||
"""OUTPUT event creates a Langfuse event."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.OUTPUT.value,
|
||||
tool_result_summary="Final answer",
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.start_observation.assert_called_once()
|
||||
call_kwargs = sink._client.start_observation.call_args.kwargs
|
||||
assert call_kwargs.get("as_type") == "event"
|
||||
assert call_kwargs.get("name") == "output"
|
||||
|
||||
def test_emit_when_disabled_is_noop(self):
|
||||
"""emit() does nothing when sink is disabled."""
|
||||
cfg = LangfuseSinkConfig(enabled=False)
|
||||
sink = LangfuseSink(cfg)
|
||||
mock_client = MagicMock()
|
||||
sink._client = mock_client
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_START.value)
|
||||
sink.emit(event)
|
||||
|
||||
mock_client.trace.assert_not_called()
|
||||
|
||||
def test_emit_when_closed_is_noop(self):
|
||||
"""emit() does nothing after close()."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
sink._closed = True
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_START.value)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.trace.assert_not_called()
|
||||
|
||||
def test_emit_exception_does_not_propagate(self):
|
||||
"""Exceptions inside emit() are caught and do not bubble up."""
|
||||
sink = _make_sink_with_mock_client()
|
||||
sink._client.trace.side_effect = RuntimeError("Langfuse network error")
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_START.value)
|
||||
sink.emit(event) # Must NOT raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLangfuseSinkLifecycle:
|
||||
def test_flush_calls_client_flush(self):
|
||||
sink = _make_sink_with_mock_client()
|
||||
sink.flush()
|
||||
sink._client.flush.assert_called_once()
|
||||
|
||||
def test_flush_after_close_is_noop(self):
|
||||
sink = _make_sink_with_mock_client()
|
||||
sink.close()
|
||||
sink._client.flush.reset_mock()
|
||||
sink.flush()
|
||||
sink._client.flush.assert_not_called()
|
||||
|
||||
def test_close_flushes_and_closes_remaining_spans(self):
|
||||
sink = _make_sink_with_mock_client()
|
||||
mock_span = MagicMock()
|
||||
sink._spans["agent1-agent1"] = mock_span
|
||||
|
||||
sink.close()
|
||||
|
||||
sink._client.flush.assert_called_once()
|
||||
mock_span.end.assert_called_once()
|
||||
assert sink._closed is True
|
||||
assert not sink._spans
|
||||
|
||||
def test_double_close_is_safe(self):
|
||||
sink = _make_sink_with_mock_client()
|
||||
sink.close()
|
||||
sink.close() # Should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol compliance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLangfuseSinkProtocol:
|
||||
def test_implements_trace_sink_protocol(self):
|
||||
"""LangfuseSink satisfies TraceSinkProtocol at runtime."""
|
||||
sink = LangfuseSink(LangfuseSinkConfig(enabled=False))
|
||||
assert isinstance(sink, TraceSinkProtocol)
|
||||
Generated
+29
-2
@@ -2580,6 +2580,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/66/d9e0c3b83b0ad75ee746c51ba347cacecb8d656b96e1d513f3e334d1ccab/langchain_text_splitters-1.1.1-py3-none-any.whl", hash = "sha256:5ed0d7bf314ba925041e7d7d17cd8b10f688300d5415fb26c29442f061e329dc", size = 35734, upload-time = "2026-02-18T23:02:41.913Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langfuse"
|
||||
version = "4.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/d0/6d79ed5614f86f27f5df199cf10c6facf6874ff6f91b828ae4dad90aa86d/langfuse-4.0.6.tar.gz", hash = "sha256:83a6f8cc8f1431fa2958c91e2673bc4179f993297e9b1acd1dbf001785e6cf83", size = 274094, upload-time = "2026-04-01T20:04:15.153Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/088048e37b6d7ec1b52c6a11bc33101454285a22eaab8303dcccfd78344d/langfuse-4.0.6-py3-none-any.whl", hash = "sha256:0562b1dcf83247f9d8349f0f755eaed9a7f952fee67e66580970f0738bf3adbf", size = 472841, upload-time = "2026-04-01T20:04:16.451Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.10"
|
||||
@@ -4788,6 +4807,7 @@ all = [
|
||||
{ name = "flask" },
|
||||
{ name = "gradio" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "langfuse" },
|
||||
{ name = "playwright" },
|
||||
{ name = "plotly" },
|
||||
{ name = "psutil" },
|
||||
@@ -4865,6 +4885,7 @@ claw = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "flask" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "langfuse" },
|
||||
{ name = "playwright" },
|
||||
{ name = "praisonaiagents", extra = ["all"] },
|
||||
{ name = "psutil" },
|
||||
@@ -4904,6 +4925,9 @@ dynamodb = [
|
||||
gradio = [
|
||||
{ name = "gradio" },
|
||||
]
|
||||
langfuse = [
|
||||
{ name = "langfuse" },
|
||||
]
|
||||
mongodb = [
|
||||
{ name = "pymongo" },
|
||||
]
|
||||
@@ -4996,6 +5020,9 @@ requires-dist = [
|
||||
{ name = "greenlet", marker = "extra == 'claw'", specifier = ">=3.0.3" },
|
||||
{ name = "greenlet", marker = "extra == 'code'", specifier = ">=3.0.3" },
|
||||
{ name = "greenlet", marker = "extra == 'realtime'", specifier = ">=3.0.3" },
|
||||
{ name = "langfuse", marker = "extra == 'all'", specifier = ">=3.0.0" },
|
||||
{ name = "langfuse", marker = "extra == 'claw'", specifier = ">=3.0.0" },
|
||||
{ name = "langfuse", marker = "extra == 'langfuse'", specifier = ">=3.0.0" },
|
||||
{ name = "litellm", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "markdown", specifier = ">=3.5" },
|
||||
{ name = "mcp", specifier = ">=1.20.0" },
|
||||
@@ -5074,7 +5101,7 @@ requires-dist = [
|
||||
{ name = "yfinance", marker = "extra == 'all'", specifier = ">=0.2.44" },
|
||||
{ name = "yfinance", marker = "extra == 'realtime'", specifier = ">=0.2.44" },
|
||||
]
|
||||
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "sandbox", "realtime", "call", "train", "crewai", "autogen", "ag2", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "redis", "mongodb", "postgresql", "dynamodb", "claw", "dev"]
|
||||
provides-extras = ["ui", "gradio", "api", "os", "agentops", "langfuse", "chat", "code", "sandbox", "realtime", "call", "train", "crewai", "autogen", "ag2", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "redis", "mongodb", "postgresql", "dynamodb", "claw", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-tools"
|
||||
@@ -5090,7 +5117,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.112"
|
||||
version = "1.5.117"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Reference in new issue
Block a user