mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 11:51:37 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80bff2d9a | ||
|
|
3e29936e86 | ||
|
|
34c6423dbc | ||
|
|
7eb01489fd | ||
|
|
731e222a53 | ||
|
|
df50822ff6 | ||
|
|
97141c75bd | ||
|
|
c660495911 | ||
|
|
0cb35a4fe3 | ||
|
|
3622b1a3d9 | ||
|
|
20d418e1de |
No files matched your search
@@ -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'
|
||||
})
|
||||
@@ -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.113" \
|
||||
"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.113" \
|
||||
"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.113" \
|
||||
"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, 'ContextConfig', 'ContextManager']] = None,
|
||||
autonomy: Optional[Union[bool, 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[str, 'OutputConfig']] = None,
|
||||
execution: Optional[Union[str, 'ExecutionConfig']] = None,
|
||||
templates: Optional['TemplateConfig'] = None,
|
||||
caching: Optional[Union[bool, 'CachingConfig']] = None,
|
||||
hooks: Optional[Union[List[Any], 'HooksConfig']] = None,
|
||||
skills: Optional[Union[List[str], 'SkillsConfig']] = None,
|
||||
approval: Optional[Union[bool, str, '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, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -790,6 +796,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 +805,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
|
||||
@@ -1518,6 +1526,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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.113"
|
||||
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.113"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-05",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-01",
|
||||
"lastUpdated": "2026-04-05",
|
||||
"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-05
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -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.113.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.113.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}"}
|
||||
|
||||
@@ -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.113 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -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.113"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.112",
|
||||
"praisonaiagents>=1.5.113",
|
||||
"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.113"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Reference in new issue
Block a user