Compare commits

...
Author SHA1 Message Date
MervinPraison d80bff2d9a Release v4.5.113 2026-04-05 05:56:45 +01:00
MervinPraison 3e29936e86 docs: auto-update api.md [skip ci] 2026-04-05 04:35:58 +00:00
34c6423dbc feat: Langfuse Observability Integration — CLI, LangfuseSink Adapter & Docker Hosting (#1271)
* feat: Add Langfuse observability integration

- Implement LangfuseSink adapter following TraceSinkProtocol
- Add complete praisonai langfuse CLI command group
- Support local (Docker) and remote Langfuse instances
- Add langfuse>=3.0.0 as optional dependency
- Maps ActionEvents to Langfuse trace/span/event API
- Thread-safe with lazy imports following AGENTS.md guidelines
- Zero changes to core SDK (praisonaiagents)

Closes #1270

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* fix: Address review feedback — fix bugs, add tests, improve code quality

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/178e99c1-305d-436d-8385-6e9f0642fec2

Co-authored-by: MervinPraison <[email protected]>

* fix: Critical Langfuse integration fixes

- Update LangfuseSink to use Langfuse v3+ API (start_observation vs trace/span/event)
- Fix CLI port parsing for Docker Compose JSON format (Publishers is array not string)
- Fix config->test workflow: load ~/.praisonai/langfuse.env in test command
- Improve agent/tool span collision safety with unique keys
- Add proper UUID-based tool invocation tracking

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-04-05 05:35:41 +01:00
MervinPraison 7eb01489fd docs: auto-update feature parity trackers [skip ci] 2026-04-05 04:24:45 +00:00
731e222a53 fix(ci): integrate Claude final review into auto-pr-comment workflow (#1272)
* fix(ci): integrate Claude final review directly into auto-pr-comment workflow to bypass action_required approval gate

* fix(ci): address review issues in auto-pr-comment workflow

- Fix template injection: use env vars + process.env instead of inline ${{ }} in JS
- Add timeout-minutes: 15 to polling jobs (prevent runaway Actions minutes)
- Add issues: write permission to all Claude-trigger jobs
- Restore claude-after-gemini job lost when chain-claude-after-copilot.yml was deleted
- DRY: extract polling constants and trigger login list to workflow-level env vars

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0ff67a7f-85be-4e2e-a16f-f3f48ebfefb5

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-04-05 05:24:28 +01:00
df50822ff6 fix: remove global logging monkey-patch from memory.py (#1268)
* fix: remove global logging monkey-patch from memory.py

Remove logging.Logger.trace monkey-patch that violated protocol-driven
architecture by modifying global state at import time. The trace
functionality remains available locally within the memory module.

This addresses one architectural violation from issue #1262 while
maintaining backward compatibility and keeping changes minimal.

Co-authored-by: Mervin Praison <[email protected]>

* fix: replace logger.trace() with logger.log(TRACE_LEVEL) after removing monkey-patch

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/56f044b6-1424-4985-b09b-afe047dadd8b

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-04-05 05:24:26 +01:00
MervinPraison 97141c75bd docs: auto-update feature parity trackers [skip ci] 2026-04-04 07:38:01 +00:00
c660495911 fix: address critical architecture gaps vs core philosophy (#1274)
* fix: address critical architecture gaps vs core philosophy

Fixes 3 critical architecture violations:

Gap 1: YAML Feature Parity - Added missing features
- Added autonomy, guardrails, approval support to YAML configuration
- Updated agents_generator.py to extract and pass these configs to PraisonAgent
- Added CLI field mapping (--trust -> approval, --guardrail -> guardrails)

Gap 2: Agent Constructor Parameter Cleanup - Improved API
- Replaced Optional[Any] type annotations with proper Union types
- Added forward references for better IDE support and type checking

Gap 3: Database Implementation Configuration - Made constants configurable
- Added tool_output_limit field to OutputConfig (default 16000)
- Made hardcoded DEFAULT_TOOL_OUTPUT_LIMIT configurable via OutputConfig
- Updated agent.tool_output_limit usage in tool_execution.py

All changes maintain backward compatibility and follow AGENTS.md guidelines.
Tested: Basic agent creation, YAML parsing, configurable limits work correctly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* docs: update _merge_cli_config docstring to list new agent-level fields

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/908b1ad0-b9c1-4fda-a330-bba68180b90c

Co-authored-by: MervinPraison <[email protected]>

* fix: address all critical issues identified by code reviewers

- Fix type annotations in Agent.__init__ to include missing str, dict types
- Add missing TYPE_CHECKING imports for forward references
- Fix YAML approval dict handling to prevent runtime crashes
- Add safe fallback for ToolExecutionMixin.tool_output_limit
- Forward missing CLI flags (autonomy, guardrail, approval) to YAML config
- Add validation to OutputConfig.tool_output_limit
- Fix unused variable warning in field mappings loop

Resolves issues found by CodeRabbitAI, Qodo, Gemini, and Copilot reviewers.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-04-04 08:37:43 +01:00
MervinPraison 0cb35a4fe3 fix(security): patch path traversal and tar slip vulnerabilities in recipe registry
- Add _safe_extractall() to reject absolute paths, '..' traversal, and path escape in tar archives
- Validate name/version from manifest BEFORE any filesystem operation in LocalRegistry.publish
- Add path confinement check ensuring resolved recipe_dir stays under recipes_path
- Validate URL path params in RegistryServer publish endpoint (early 400 rejection)
- Replace vulnerable tar.extractall() calls in both LocalRegistry and HttpRegistry
- Bump version to 4.5.113
2026-04-03 08:15:18 +01:00
MervinPraison 3622b1a3d9 docs: auto-update feature parity trackers [skip ci] 2026-04-02 21:56:14 +00:00
20d418e1de Fix: Thread-safe global singletons for multi-agent safety (#1264)
* fix: Add thread-safe locks to global singletons for multi-agent safety

- Added threading.Lock protection to 10+ global singleton instances
- Fixed race conditions in OpenAI client creation (_global_client_lock)
- Protected error_logs, callback registries with locks in main.py
- Made telemetry, trace emitter, circuit breaker thread-safe
- Converted eager singletons to lazy initialization (health_monitor, circuit_breaker)
- Fixed approval registry with lazy __getattr__ pattern
- Added locks to schedule store and hook registry

Resolves thread-safety violations per AGENTS.md section 4.5:
'No shared mutable global state between agents'

Co-authored-by: Mervin Praison <[email protected]>

* fix: Complete thread safety for remaining singleton race conditions

- OpenAI client: Move return statement inside lock block (get_openai_client)
- Schedule tools: Add missing threading import to prevent NameError
- Telemetry: Protect enable_telemetry() with instance lock

Addresses critical race conditions identified in code review.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-02 22:55:58 +01:00
47 changed files with 1926 additions and 215 deletions

No files matched your search

+309 -6
View File
@@ -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'
})
+7
View File
@@ -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>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.112" \
"praisonai>=4.5.113" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.112" \
"praisonai>=4.5.113" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.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
+28 -15
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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]"
+65 -3
View File
@@ -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:
+12
View File
@@ -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
+2
View File
@@ -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
+21
View File
@@ -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):
+1 -1
View File
@@ -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
+56 -3
View File
@@ -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,
+16
View File
@@ -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:
+14 -10
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "4.5.112"
__version__ = "4.5.113"
+6 -1
View File
@@ -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)
+29 -2
View File
@@ -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" },