mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3a689f8c | ||
|
|
7313b64544 | ||
|
|
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'
|
||||
})
|
||||
@@ -11,10 +11,115 @@ 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 }}
|
||||
|
||||
- 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')) &&
|
||||
(
|
||||
@@ -51,7 +156,6 @@ jobs:
|
||||
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.
|
||||
@@ -63,15 +167,18 @@ jobs:
|
||||
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 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 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:
|
||||
- Create a fix branch and implement a minimal, focused fix
|
||||
|
||||
@@ -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.115" \
|
||||
"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.115" \
|
||||
"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.115" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -176,6 +176,12 @@ _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
|
||||
@@ -476,24 +482,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 +544,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 +581,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 +808,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 +817,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 +1088,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 +1554,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 +1679,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 +1992,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 +2266,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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,30 @@ 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()
|
||||
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
|
||||
# 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Optional, List, Any, AsyncGenerator
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -25,6 +26,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,6 +40,8 @@ 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
|
||||
@@ -233,7 +238,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 +253,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 +388,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 +424,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 +468,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 +581,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 +595,29 @@ 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)
|
||||
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 +1135,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 +1248,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(
|
||||
|
||||
@@ -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
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.112"
|
||||
version = "1.5.116"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Generated
+1
-1
@@ -2951,7 +2951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.112"
|
||||
version = "1.5.116"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-07",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
@@ -1705,7 +1705,6 @@
|
||||
"planning": [],
|
||||
"plugins": [
|
||||
"PluginRegistry",
|
||||
"ToolDefinition",
|
||||
"disable_plugins",
|
||||
"enable_plugins",
|
||||
"get_plugin_manager",
|
||||
@@ -1730,6 +1729,7 @@
|
||||
"tools": [
|
||||
"FunctionTool",
|
||||
"Tool",
|
||||
"ToolDefinition",
|
||||
"ToolRegistry",
|
||||
"ToolResult"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-07",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-01
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-07
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { AgentFlow, Task, TaskConfig } from './index';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface YAMLWorkflowDefinition {
|
||||
name: string;
|
||||
@@ -39,8 +40,9 @@ export interface ParsedWorkflow {
|
||||
* 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 +84,15 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
|
||||
type: 'agent'
|
||||
};
|
||||
} else if (currentStep) {
|
||||
// Step properties
|
||||
// Step properties — whitelist allowed keys to prevent injection
|
||||
const ALLOWED_STEP_KEYS = new Set([
|
||||
'type', 'agent', 'tool', 'input', 'output', 'condition',
|
||||
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
|
||||
]);
|
||||
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,9 +275,33 @@ 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');
|
||||
|
||||
// SECURITY: Prevent path traversal
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
if (normalizedPath.includes('..')) {
|
||||
throw new Error('Path traversal detected: ".." is not allowed in file paths');
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: Enforce file size limit (default 1 MB)
|
||||
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > maxSize) {
|
||||
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, '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.115.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.115.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,48 @@ class AgentsGenerator:
|
||||
else:
|
||||
agent_planning.pop('planning_tools')
|
||||
|
||||
# Extract YAML configuration for advanced features
|
||||
autonomy_config = details.get('autonomy')
|
||||
guardrails_config = details.get('guardrails')
|
||||
|
||||
# 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
|
||||
|
||||
agent = PraisonAgent(
|
||||
name=role_filled,
|
||||
role=role_filled,
|
||||
@@ -1191,6 +1250,9 @@ class AgentsGenerator:
|
||||
reflection=details.get('reflection', False),
|
||||
tool_timeout=agent_tool_timeout,
|
||||
planning=agent_planning,
|
||||
autonomy=autonomy_config,
|
||||
guardrails=guardrails_config,
|
||||
approval=approval_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}"}
|
||||
|
||||
@@ -15,9 +15,75 @@ if TYPE_CHECKING:
|
||||
from .code_intelligence import CodeIntelligenceRouter
|
||||
from .action_orchestrator import ActionOrchestrator
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_filepath(filepath: str, workspace: 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 = os.path.realpath(os.path.join(workspace, normalized))
|
||||
ws_resolved = os.path.realpath(workspace)
|
||||
if not resolved.startswith(ws_resolved + os.sep) and resolved != ws_resolved:
|
||||
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
|
||||
';', # Command separator
|
||||
'>>', '> ', # Output redirection
|
||||
'| ', # Pipe
|
||||
]
|
||||
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 +143,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 +216,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 +272,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 +325,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 +510,16 @@ 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()
|
||||
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
|
||||
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 +553,13 @@ 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()
|
||||
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
|
||||
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
|
||||
|
||||
@@ -3996,6 +3996,27 @@ 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
|
||||
|
||||
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.115 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
|
||||
@@ -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.115"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.112",
|
||||
"praisonaiagents>=1.5.115",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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_trace = MagicMock()
|
||||
sink._client.trace.return_value = mock_trace
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_START.value)
|
||||
sink.emit(event)
|
||||
|
||||
sink._client.trace.assert_called_once()
|
||||
mock_trace.span.assert_called_once()
|
||||
assert "agent1" in sink._traces
|
||||
assert "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"] = mock_span
|
||||
sink._traces["agent1"] = MagicMock()
|
||||
|
||||
event = _make_event(ActionEventType.AGENT_END.value, status="ok")
|
||||
sink.emit(event)
|
||||
|
||||
mock_span.end.assert_called_once()
|
||||
assert "agent1" not in sink._spans
|
||||
assert "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()
|
||||
mock_parent_span.span.return_value = mock_tool_span
|
||||
sink._spans["agent1"] = mock_parent_span
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.TOOL_START.value,
|
||||
tool_name="search_tool",
|
||||
tool_args={"query": "test"},
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
mock_parent_span.span.assert_called_once()
|
||||
tool_keys = [k for k in sink._spans if k.startswith("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: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()
|
||||
mock_trace = MagicMock()
|
||||
sink._traces["agent1"] = mock_trace
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.ERROR.value,
|
||||
error_message="Something went wrong",
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
mock_trace.event.assert_called_once()
|
||||
call_kwargs = mock_trace.event.call_args.kwargs
|
||||
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()
|
||||
mock_trace = MagicMock()
|
||||
sink._traces["agent1"] = mock_trace
|
||||
|
||||
event = _make_event(
|
||||
ActionEventType.OUTPUT.value,
|
||||
tool_result_summary="Final answer",
|
||||
)
|
||||
sink.emit(event)
|
||||
|
||||
mock_trace.event.assert_called_once()
|
||||
|
||||
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"] = 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.116"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
roles:
|
||||
researcher:
|
||||
name: "Research Agent"
|
||||
role: "Information Researcher"
|
||||
goal: "Find and analyze information using multiple tools"
|
||||
backstory: "You are an expert researcher with access to multiple information sources"
|
||||
llm: "gpt-4o-mini"
|
||||
tools:
|
||||
- search_web
|
||||
- get_weather
|
||||
- calculate
|
||||
tasks:
|
||||
test_task:
|
||||
description: "Use all available tools to gather information about {topic}"
|
||||
expected_output: "A comprehensive report using data from all tools"
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
|
||||
Returns:
|
||||
Search results
|
||||
"""
|
||||
return f"Web search results for: {query}"
|
||||
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get weather information for a location.
|
||||
|
||||
Args:
|
||||
location: The location to get weather for
|
||||
|
||||
Returns:
|
||||
Weather information
|
||||
"""
|
||||
return f"Weather in {location}: Sunny, 25°C"
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Perform a calculation.
|
||||
|
||||
Args:
|
||||
expression: Mathematical expression to evaluate
|
||||
|
||||
Returns:
|
||||
Calculation result
|
||||
"""
|
||||
try:
|
||||
result = eval(expression)
|
||||
return f"Result: {result}"
|
||||
except:
|
||||
return "Invalid expression"
|
||||
Reference in new issue
Block a user