Compare commits

...
Author SHA1 Message Date
MervinPraison bbcc505693 Release v4.5.114 2026-04-07 21:37:51 +01:00
MervinPraison cc0544a4e3 feat(context): enhance agent ledger and monitor functionality
- Improved `get_agent_ledger` method in `ledger.py` by adding validation
  for `agent_id` to raise a `ValueError` if it is empty.
- Updated `_get_output_path` in `monitor.py` to include detailed path
  sanitization steps and added checks to prevent path traversal attacks.
- Refined agent name sanitation and fallback techniques to ensure unique
  naming.
- Incremented version number to `1.5.114` in `pyproject.toml` and
  `uv.lock`.
2026-04-07 21:23:57 +01:00
Mervin Praison b8455205f6 Merge pull request #1278 from MervinPraison/fix/agent-init-type-hints
fix(types): widen type hints for agent init
2026-04-07 11:48:37 +01:00
MervinPraison a10af4e839 docs: auto-update feature parity trackers [skip ci] 2026-04-07 10:46:37 +00:00
Mervin Praison ff37cbcf6f Merge pull request #1279 from MervinPraison/claude/issue-1261-20260405-0507
Revert broken async sleep helpers in execution_mixin.py
2026-04-07 11:46:15 +01:00
copilot-swe-agent[bot]andMervinPraison a42c6d325e fix(types): restore type hints that have runtime backing, narrow those that don't
After rebase, commit 72e477f added runtime support for:
- context str/dict -> restore to type hint
- autonomy str -> restore to type hint
- approval dict -> restore to type hint
- learn str -> restore to type hint

templates str remains removed (resolve() with no presets returns None).
Updated all docstrings to document actual supported types including new forms.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/e371c8e1-a816-4fb2-8eb8-a18242daa2de

Co-authored-by: MervinPraison <[email protected]>
2026-04-05 05:24:10 +00:00
copilot-swe-agent[bot]andMervinPraison 724e4af963 fix(types): address reviewer feedback on agent init type hints
- Narrow context hint: remove str/Dict (init has no handling for those)
- Narrow autonomy hint: remove str (no string-level preset handling)
- Narrow templates hint: remove str (resolve() returns None with no presets)
- Narrow approval hint: remove Dict (init only handles bool/str/ApprovalConfig/Protocol)
- Narrow learn hint: remove str; replace silent passthrough with a logged warning
- Update docstrings for output/execution/templates/learn to reflect actual supported types

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/e371c8e1-a816-4fb2-8eb8-a18242daa2de

Co-authored-by: MervinPraison <[email protected]>
2026-04-05 05:21:29 +00:00
MervinPraison 7cc9718c41 Fix missing Mount import in MCP server launch
- Add missing 'from starlette.routing import Mount' import
- Mount was used on line 1046 but not imported
- Fixes NameError when launching agents in MCP mode

🤖 Generated with Claude Code

Co-authored-by: MervinPraison <[email protected]>
2026-04-05 05:21:23 +00:00
MervinPraisonandMervin Praison 72e477f4a3 fix: implement runtime support for widened type hints in Agent init
- Add string preset handling in _init_autonomy() for autonomy='full_auto' etc.
- Add str/dict support in context_manager property for context='summarize' etc.
- Add dict-to-ApprovalConfig conversion for approval={'backend': ..., 'all_tools': True}
- Add string mode handling in learn parameter for learn='agentic' etc.
- Fix AUTONOMY_PRESETS to use 'level' field (not 'mode') per AutonomyConfig

Resolves type hint/implementation mismatches identified by code reviewers.
All new type hints now have proper runtime support.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-05 05:20:44 +00:00
copilot-swe-agent[bot]andMervinPraison ecfd858269 Remove broken _smart_sleep/_async_safe_sleep helpers; restore time.sleep() in sync methods
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/4e986fa2-76e0-466f-a027-82bf3291d31a

Co-authored-by: MervinPraison <[email protected]>
2026-04-05 05:18:48 +00:00
Mervin Praison 92c6690ab2 Merge pull request #1277 from MervinPraison/claude/issue-1276-20260404-1738
fix: resolve critical architecture gaps for multi-agent safety
2026-04-05 06:15:27 +01:00
MervinPraisonandMervin Praison efb92fdd2e Fix blocking time.sleep() calls in async code paths
Replace 6 blocking time.sleep() calls with async-safe alternatives to prevent event loop blocking in multi-agent workflows.

- Added _smart_sleep() utility that detects async context
- Added _async_safe_sleep() utility for async-safe sleeping
- Replaced blocking calls at lines 956, 959, 988, 997, 1056, 1061
- Maintains backward compatibility for sync contexts
- No new dependencies added
- Follows AGENTS.md principles: async-safe by default and performance-first

Fixes #1261

Co-authored-by: Mervin Praison <[email protected]>
2026-04-05 05:10:34 +00:00
MervinPraison 9c2f14e042 fix(types): widen type hints for agent init 2026-04-05 06:09:32 +01:00
MervinPraisonandClaude 8b0567262d fix: address review issues - tool factory performance and memory learn method
- Optimize tool factory: cache classes but create fresh instances (prevents import overhead while maintaining multi-agent safety)
- Fix memory core learn method: add missing return statement
- Address CodeRabbit feedback on unreachable code patterns

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

Co-Authored-By: Claude <[email protected]>
2026-04-05 05:08:56 +00:00
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
MervinPraisonandMervin Praison 6b4e1b3590 fix: remove global shared state from observability hooks
- Remove _global_hooks, enable_observability(), disable_observability(), and get_hooks()
- Observability is now strictly per-agent/per-pipeline to comply with AGENTS.md Rule 4.5
- Agent class already correctly uses per-instance ObservabilityHooks() when observe=True
- Eliminates shared mutable state between agents for multi-agent safety

Addresses critical architecture gap identified in PR #1277 final review.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-05 04:31:54 +00: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
copilot-swe-agent[bot]andMervinPraison 7480c4389a docs: improve comment for _sanitize_metadata in store_short_term_async
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5f375fcb-adf2-43cf-9f54-a664458ef3d7

Co-authored-by: MervinPraison <[email protected]>
2026-04-04 18:05:49 +00:00
copilot-swe-agent[bot]andMervinPraison 494cd8b44a fix: address review issues - fix task_prompt dead code, workflow_timeout enforcement, and memory async correctness
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5f375fcb-adf2-43cf-9f54-a664458ef3d7

Co-authored-by: MervinPraison <[email protected]>
2026-04-04 18:05:02 +00:00
MervinPraisonandMervin Praison 2e733e16d7 fix: resolve critical architecture gaps for multi-agent safety
Fixes 3 critical architecture gaps identified in issue #1276:

**Issue 1: Global Shared Mutable State**
- Replace tool singleton cache with factory pattern for per-agent isolation
- Use _state_lock for atomic task status operations
- Add thread safety to observability hooks

**Issue 2: Unsafe Process Orchestration**
- Fix context accumulation bug in task descriptions
- Add bounded retry counter with configurable max_retries
- Add workflow timeout support for production safety

**Issue 3: Async/Sync Boundary Violations**
- Add async memory operations using asyncio.to_thread()
- Prevent event loop blocking in memory auto-promotion

Changes maintain protocol-driven design with no new dependencies.
All fixes are backward compatible and follow AGENTS.md guidelines.

Resolves #1276

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-04-04 17:46:13 +00: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
59 changed files with 2446 additions and 304 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.114" \
"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.114" \
"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.114" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -176,6 +176,12 @@ _shared_apps = {} # Dict of port -> FastAPI app
# Don't import FastAPI dependencies here - use lazy loading instead
if TYPE_CHECKING:
from ..approval.protocols import ApprovalConfig, ApprovalProtocol
from ..config.feature_configs import LearnConfig, MemoryConfig
from ..context.models import ContextConfig
from ..context.manager import ContextManager
from ..knowledge.knowledge import Knowledge
from ..agent.autonomy import AutonomyConfig
from ..task.task import Task
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
@@ -476,24 +482,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
# Each follows: False=disabled, True=defaults, Config=custom
# ============================================================
memory: Optional[Any] = None, # Union[bool, MemoryConfig, MemoryManager]
knowledge: Optional[Union[bool, List[str], Any]] = None, # Union[bool, list, KnowledgeConfig, Knowledge]
planning: Optional[Union[bool, Any]] = False, # Union[bool, PlanningConfig]
reflection: Optional[Union[bool, Any]] = None, # Union[bool, ReflectionConfig]
guardrails: Optional[Union[bool, Callable, Any]] = None, # Union[bool, Callable, GuardrailConfig]
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
guardrails: Optional[Union[bool, str, Callable, 'GuardrailConfig']] = None,
web: Optional[Union[bool, str, 'WebConfig']] = None,
context: Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']] = None,
autonomy: Optional[Union[bool, str, Dict[str, Any], 'AutonomyConfig']] = None,
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
templates: Optional[Any] = None, # TemplateConfig
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
output: Optional[Union[bool, str, Dict[str, Any], 'OutputConfig']] = None,
execution: Optional[Union[bool, str, Dict[str, Any], 'ExecutionConfig']] = None,
templates: Optional[Union[Dict[str, Any], 'TemplateConfig']] = None,
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
):
"""Initialize an Agent instance.
@@ -538,22 +544,32 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
- WebConfig: Custom configuration
context: Context management. Accepts:
- bool: True enables with defaults
- ManagerConfig: Custom configuration
- str: Preset name ("sliding_window", "summarize", "truncate")
- Dict[str, Any]: ContextConfig fields
- ContextConfig: Custom configuration
- ContextManager: Pre-configured instance
autonomy: Autonomy settings. Accepts:
- bool: True enables with defaults
- Dict: Configuration dict
- str: Level preset ("suggest", "auto_edit", "full_auto")
- Dict[str, Any]: Configuration dict
- AutonomyConfig: Custom configuration
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
Still works for backward compatibility.
output: Output configuration. Accepts:
- bool: True=default OutputConfig, False=disabled
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
- Dict[str, Any]: Config overrides (e.g. {"verbose": 2, "stream": True})
- OutputConfig: Custom configuration
Controls: verbose, markdown, stream, metrics, reasoning_steps
execution: Execution configuration. Accepts:
- bool: True=default ExecutionConfig, False=disabled
- str: Preset name ("fast", "balanced", "thorough")
- Dict[str, Any]: Config overrides (e.g. {"max_iter": 10, "max_rpm": 60})
- ExecutionConfig: Custom configuration
Controls: max_iter, max_rpm, max_execution_time, max_retry_limit
templates: Template configuration (TemplateConfig).
templates: Template configuration. Accepts:
- Dict[str, Any]: Template fields (e.g. {"system": "...", "prompt": "..."})
- TemplateConfig: Custom configuration
Controls: system_template, prompt_template, response_template
caching: Caching configuration. Accepts:
- bool: True enables with defaults
@@ -565,7 +581,9 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
- List[str]: Skill directory paths
- SkillsConfig: Custom configuration
learn: Continuous learning configuration. Accepts:
- bool: True enables with defaults, False disables
- bool: True enables with defaults (AGENTIC mode), False disables
- str: Mode string ("disabled", "agentic", "propose")
- Dict[str, Any]: Config fields (e.g. {"mode": "agentic", "backend": "sqlite"})
- LearnConfig: Custom configuration
Learning is a first-class citizen, peer to memory. It captures patterns,
preferences, and insights from interactions to improve future responses.
@@ -790,6 +808,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
output_template = getattr(_output_config, 'template', None) # Response template
tool_output_limit = getattr(_output_config, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT)
else:
# Fallback defaults match silent mode (zero overhead)
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
@@ -798,6 +817,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
status_trace = False
simple_output = False
editor_output = False
tool_output_limit = DEFAULT_TOOL_OUTPUT_LIMIT
# Enable editor output mode if configured (beginner-friendly, takes priority)
# Shows: Step 1: 📄 Creating file: path → ✓ Done
@@ -1068,8 +1088,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
_learn_config = learn
elif isinstance(learn, dict):
_learn_config = LearnConfig(**learn)
elif isinstance(learn, str):
# String mode: "disabled", "agentic", "propose"
from ..memory.learn.protocols import LearnMode
if learn == "disabled":
_learn_config = None
elif learn == "agentic":
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
elif learn == "propose":
_learn_config = LearnConfig(mode=LearnMode.PROPOSE)
else:
# Unknown string mode, disable learning
_learn_config = None
else:
_learn_config = learn # Pass through
logging.warning(
"Unsupported learn= value %r; expected bool, dict, or LearnConfig. "
"Learning disabled.", learn
)
_learn_config = None
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
# Fallback to memory.learn for backward compatibility
if _memory_config.learn:
@@ -1518,6 +1554,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
self._init_memory(memory, user_id)
self.verbose = verbose
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
self.tool_output_limit = tool_output_limit # Configurable tool output limit
self.allow_delegation = allow_delegation
self.step_callback = step_callback
# Token budget guard (zero overhead when _max_budget is None)
@@ -1642,6 +1679,12 @@ Your Goal: {self.goal}
self._approval_backend = approval.backend
self._approve_all_tools = approval.all_tools
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
elif isinstance(approval, dict):
# Dict config: convert to ApprovalConfig
approval_config = ApprovalConfig(**approval)
self._approval_backend = approval_config.backend
self._approve_all_tools = approval_config.all_tools
self._approval_timeout = approval_config.timeout
else:
# Plain backend object — dangerous tools only, backend default timeout
self._approval_backend = approval
@@ -1949,6 +1992,64 @@ Your Goal: {self.goal}
elif hasattr(self._context_param, 'process'):
# Already a ContextManager instance
self._context_manager = self._context_param
elif isinstance(self._context_param, str):
# String preset: "sliding_window", "summarize", "truncate"
from ..config.presets import CONTEXT_PRESETS
preset_config = CONTEXT_PRESETS.get(self._context_param)
if preset_config is not None:
# Convert preset to ContextConfig, then to ManagerConfig
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**preset_config)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
config=manager_config,
agent_name=self.name or "Agent",
session_cache=self._session_dedup_cache,
llm_summarize_fn=None,
)
except Exception as e:
logging.debug(f"Context preset conversion failed: {e}")
self._context_manager = None
else:
# Unknown string preset, disable
self._context_manager = None
elif isinstance(self._context_param, dict):
# Dict config: convert to ContextConfig, then to ManagerConfig
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**self._context_param)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
config=manager_config,
agent_name=self.name or "Agent",
session_cache=self._session_dedup_cache,
llm_summarize_fn=self._create_llm_summarize_fn() if llm_summarize_enabled else None,
)
except Exception as e:
logging.debug(f"Context dict conversion failed: {e}")
self._context_manager = None
else:
# Unknown type, disable
self._context_manager = None
@@ -2165,6 +2266,25 @@ Summary:"""
# Extract verification_hooks from AutonomyConfig if provided
if autonomy.verification_hooks and not verification_hooks:
self._verification_hooks = autonomy.verification_hooks
elif isinstance(autonomy, str):
# String preset: "suggest", "auto_edit", "full_auto"
from ..config.presets import AUTONOMY_PRESETS
preset_config = AUTONOMY_PRESETS.get(autonomy)
if preset_config is not None:
config = AutonomyConfig.from_dict(preset_config)
else:
# Unknown string preset — disable autonomy
self.autonomy_enabled = False
self.autonomy_config = {}
self._autonomy_trigger = None
self._doom_loop_tracker = None
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = False
return
else:
self.autonomy_enabled = False
self.autonomy_config = {}
@@ -14,6 +14,7 @@ from praisonaiagents._logging import get_logger
import asyncio
import threading
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
@@ -1013,6 +1014,7 @@ Write the complete compiled report:"""
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import threading
import time
import asyncio
@@ -216,10 +216,11 @@ class ToolExecutionMixin:
else:
# Apply default limit even without context management
# This prevents runaway tool outputs from causing overflow
if len(result_str) > DEFAULT_TOOL_OUTPUT_LIMIT:
limit = getattr(self, 'tool_output_limit', 16000)
if len(result_str) > limit:
# Use smart truncation format that judge recognizes as OK
tail_size = min(DEFAULT_TOOL_OUTPUT_LIMIT // 5, 2000)
head = result_str[:DEFAULT_TOOL_OUTPUT_LIMIT - tail_size]
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
else:
@@ -229,7 +230,7 @@ class ToolExecutionMixin:
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
# For dicts, truncate large string fields (e.g., raw_content from search)
if isinstance(result, dict):
max_field_chars = DEFAULT_TOOL_OUTPUT_LIMIT if not self.context_manager else None
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars)
else:
result = truncated
@@ -50,20 +50,30 @@ logger = get_logger(__name__)
# ── Singleton registry ───────────────────────────────────────────────────────
import threading
_registry: Optional[ApprovalRegistry] = None
_registry_lock = threading.Lock()
def get_approval_registry() -> ApprovalRegistry:
"""Return the global singleton :class:`ApprovalRegistry`."""
global _registry
if _registry is None:
_registry = ApprovalRegistry()
return _registry
with _registry_lock:
if _registry is None:
_registry = ApprovalRegistry()
return _registry
# ── Backward-compatible API (delegates to registry) ─────────────────────────
# These globals are kept for code that imports them directly.
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
# NOTE: Accessing these will trigger lazy initialization of the registry
def __getattr__(name):
"""Module-level attribute access for lazy initialization."""
if name == "APPROVAL_REQUIRED_TOOLS":
return get_approval_registry()._required_tools
elif name == "TOOL_RISK_LEVELS":
return get_approval_registry()._risk_levels
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
# Legacy global callback holder — set_approval_callback wraps it into a backend
approval_callback: Optional[Callable] = None
@@ -114,14 +124,16 @@ def configure_default_approvals() -> None:
reg.add_requirement(tool_name, risk_level)
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
get_approval_registry().add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
reg = get_approval_registry()
reg.add_requirement(tool_name, risk_level)
reg._required_tools.add(tool_name)
reg._risk_levels[tool_name] = risk_level
def remove_approval_requirement(tool_name: str) -> None:
get_approval_registry().remove_requirement(tool_name)
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
TOOL_RISK_LEVELS.pop(tool_name, None)
reg = get_approval_registry()
reg.remove_requirement(tool_name)
reg._required_tools.discard(tool_name)
reg._risk_levels.pop(tool_name, None)
def is_approval_required(tool_name: str) -> bool:
return get_approval_registry().is_required(tool_name)
@@ -157,8 +169,8 @@ def require_approval(risk_level: RiskLevel = "high"):
tool_name = getattr(func, '__name__', str(func))
reg = get_approval_registry()
reg.add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
reg._required_tools.add(tool_name)
reg._risk_levels[tool_name] = risk_level
@wraps(func)
def wrapper(*args, **kwargs):
@@ -217,7 +217,11 @@ class CheckpointService:
cwd=self.config.workspace_dir
)
stdout, stderr = await process.communicate()
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
except asyncio.TimeoutError:
process.kill()
raise RuntimeError(f"Git command timed out after 30 seconds: {' '.join(args)}")
if process.returncode != 0:
error_msg = stderr.decode().strip()
@@ -626,6 +626,16 @@ class OutputConfig:
# Example: "# {{title}}\n\n{{content}}\n\n---\nGenerated by AI"
template: Optional[str] = None
# Tool output limit - maximum characters for tool output
# Default: 16000 chars (≈ 4000 tokens) to prevent runaway tool outputs
tool_output_limit: int = 16000
def __post_init__(self) -> None:
if not isinstance(self.tool_output_limit, int) or self.tool_output_limit <= 0:
raise ValueError(
"OutputConfig.tool_output_limit must be a positive integer."
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -642,6 +652,7 @@ class OutputConfig:
"editor_output": self.editor_output,
"output_file": self.output_file,
"template": self.template,
"tool_output_limit": self.tool_output_limit,
}
@@ -300,9 +300,9 @@ CONTEXT_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
AUTONOMY_PRESETS: Dict[str, Dict[str, Any]] = {
"suggest": {"mode": "suggest"},
"auto_edit": {"mode": "auto_edit"},
"full_auto": {"mode": "full_auto"},
"suggest": {"level": "suggest"},
"auto_edit": {"level": "auto_edit"},
"full_auto": {"level": "full_auto"},
}
@@ -291,7 +291,20 @@ class MultiAgentLedger:
self._shared_tokens: int = 0 # Tokens shared across agents
def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
"""Get or create ledger for an agent."""
"""Get or create ledger for an agent.
Args:
agent_id: Unique identifier for the agent.
Returns:
ContextLedgerManager for this agent.
Raises:
ValueError: If agent_id is empty or contains only whitespace.
"""
if not agent_id or not agent_id.strip():
raise ValueError("agent_id must be a non-empty string")
if agent_id not in self._agents:
self._agents[agent_id] = ContextLedgerManager(agent_id)
return self._agents[agent_id]
@@ -514,7 +514,10 @@ class ContextMonitor:
return str(output_path)
def _get_output_path(self, agent_name: str = "") -> Path:
"""Get output path, optionally per-agent."""
"""Get output path, optionally per-agent.
Security: Agent names are sanitized to prevent path traversal.
"""
if not self.multi_agent_files or not agent_name:
return self.path
@@ -522,10 +525,30 @@ class ContextMonitor:
stem = self.path.stem
suffix = self.path.suffix or (".json" if self.format == "json" else ".txt")
# Sanitize agent name
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())
# Sanitize agent name:
# 1. Strip any path separators first (defence in depth)
safe_name = agent_name.replace('/', '_').replace('\\', '_')
# 2. Collapse to safe chars only
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
# 3. Enforce length limit to prevent filesystem issues
safe_name = safe_name[:64] if safe_name else 'unknown'
# 4. Strip leading/trailing underscores / dots
safe_name = safe_name.strip('_.')
if not safe_name:
safe_name = 'unknown'
return self.path.parent / f"{stem}_{safe_name}{suffix}"
result = self.path.parent / f"{stem}_{safe_name}{suffix}"
# Verify the resolved path stays within the expected parent dir
resolved = result.resolve()
parent_resolved = self.path.parent.resolve()
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
# Fallback to a safe hash-based name
import hashlib
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
result = self.path.parent / f"{stem}_{hash_name}{suffix}"
return result
def _get_warnings(
self,
@@ -291,21 +291,6 @@ class ObservabilityHooks:
"final_stage": self._current_stage.name if self._current_stage else None,
}
# Global hooks instance (opt-in)
_global_hooks: Optional[ObservabilityHooks] = None
def get_hooks() -> Optional[ObservabilityHooks]:
"""Get global observability hooks."""
return _global_hooks
def enable_observability() -> ObservabilityHooks:
"""Enable global observability."""
global _global_hooks
if _global_hooks is None:
_global_hooks = ObservabilityHooks(enabled=True)
return _global_hooks
def disable_observability():
"""Disable global observability."""
global _global_hooks
_global_hooks = None
# NOTE: Observability is now per-agent/per-pipeline to comply with multi-agent safety.
# Each agent creates its own ObservabilityHooks instance when observe=True.
# See agent.py:2236 for the correct per-instance pattern.
@@ -5,6 +5,7 @@ Manages registration and lookup of hooks for different events.
"""
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Callable, Union
from functools import wraps
@@ -319,18 +320,21 @@ class HookRegistry:
# Global default registry
_default_registry: Optional[HookRegistry] = None
_default_registry_lock = threading.Lock()
def get_default_registry() -> HookRegistry:
"""Get the default global hook registry."""
global _default_registry
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
with _default_registry_lock:
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
def set_default_registry(registry: HookRegistry):
"""Set the default global hook registry."""
global _default_registry
_default_registry = registry
with _default_registry_lock:
_default_registry = registry
# =============================================================================
# Simplified API (beginner-friendly aliases)
@@ -11,6 +11,7 @@ from praisonaiagents._logging import get_logger
import time
import json
import asyncio
import threading
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Callable, Tuple, TYPE_CHECKING
from pydantic import BaseModel
from dataclasses import dataclass
@@ -2197,6 +2198,7 @@ class OpenAIClient:
# Global client instance (similar to main.py pattern)
_global_client = None
_global_client_params = None
_global_client_lock = threading.Lock()
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> OpenAIClient:
"""
@@ -2216,9 +2218,10 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
normalized_base_url = base_url
current_params = (normalized_api_key, normalized_base_url)
# Only create new client if parameters changed or first time
if _global_client is None or _global_client_params != current_params:
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
_global_client_params = current_params
return _global_client
# Thread-safe client creation
with _global_client_lock:
# Only create new client if parameters changed or first time
if _global_client is None or _global_client_params != current_params:
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
_global_client_params = current_params
return _global_client
+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."""
@@ -8,6 +8,7 @@ and quality scoring logic. Split from the main memory.py file for better maintai
import json
import logging
import threading
import asyncio
from typing import Any, Dict, List, Optional
from datetime import datetime
@@ -304,4 +305,86 @@ class MemoryCoreMixin:
self._learn_manager = LearnManager(config=self._learn_config)
except ImportError:
logging.warning("Learn manager not available - install learn dependencies")
return self._learn_manager
return self._learn_manager
# Async variants to prevent event loop blocking
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
"""
Async version of store_short_term to prevent event loop blocking.
Args:
content: The content to store
metadata: Optional metadata dictionary
quality_score: Optional pre-calculated quality score
user_id: Optional user identifier
auto_promote: Whether to automatically promote to LTM if quality is high
Returns:
The memory ID of the stored content
"""
if not content.strip():
return ""
# Calculate quality score if not provided
if quality_score is None:
quality_score = self.compute_quality_score(content, metadata)
# Prepare metadata (mirror sync version's metadata construction including sanitization
# to ensure only JSON-serializable values are stored, preventing crashes across backends)
raw_metadata = metadata.copy() if metadata else {}
raw_metadata.update({
"timestamp": datetime.now().isoformat(),
"quality_score": quality_score,
"memory_type": "short_term"
})
if user_id:
raw_metadata["user_id"] = user_id
clean_metadata = self._sanitize_metadata(raw_metadata)
# Store in SQLite STM
memory_id = ""
try:
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
except Exception as e:
logging.error(f"Failed to store in SQLite STM: {e}")
return ""
# Auto-promote to long-term memory if quality is high (async)
if auto_promote and quality_score >= 7.5: # High quality threshold
try:
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
except Exception as e:
logging.warning(f"Failed to auto-promote to LTM: {e}")
# Emit memory event
self._emit_memory_event("store", "short_term", content, clean_metadata)
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
return memory_id or ""
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
user_id: Optional[str] = None) -> str:
"""
Async version of store_long_term to prevent event loop blocking.
Args:
content: The content to store
metadata: Optional metadata dictionary
quality_score: Optional pre-calculated quality score
user_id: Optional user identifier
Returns:
The memory ID of the stored content
"""
if not content.strip():
return ""
# Calculate quality score if not provided
if quality_score is None:
quality_score = self.compute_quality_score(content, metadata)
# Use sync version in thread to avoid blocking event loop
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
@@ -26,12 +26,6 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, 'TRACE')
def trace(self, message, *args, **kwargs):
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
logging.Logger.trace = trace
# Thread-safe lazy imports using proper thread synchronization
import threading
@@ -942,7 +936,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
from praisonaiagents.embedding import embedding as get_embedding
logger.info("Getting embeddings...")
logger.trace(f"Embedding input text: {text}")
logger.log(TRACE_LEVEL, f"Embedding input text: {text}")
result = get_embedding(text, model=self.embedding_model)
embedding = result.embeddings[0] if result.embeddings else None
@@ -952,7 +946,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
return
logger.info("Successfully got embeddings")
logger.trace(f"Received embedding of length: {len(embedding)}")
logger.log(TRACE_LEVEL, f"Received embedding of length: {len(embedding)}")
# Sanitize metadata for ChromaDB
sanitized_metadata = self._sanitize_metadata(metadata)
@@ -1,5 +1,6 @@
import logging
import asyncio
import time
import json
from typing import Dict, Optional, List, Any, AsyncGenerator
from pydantic import BaseModel, ConfigDict
@@ -25,6 +26,8 @@ class Process:
manager_llm: Optional[str] = None,
verbose: bool = False,
max_iter: int = 10,
max_retries: int = 3,
workflow_timeout: Optional[int] = None, # seconds, None = no timeout
output: Optional[str] = None,
):
logging.debug(f"=== Initializing Process ===")
@@ -37,6 +40,8 @@ class Process:
self.agents = agents
self.manager_llm = manager_llm
self.max_iter = max_iter
self.max_retries = max_retries
self.workflow_timeout = workflow_timeout
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
self.workflow_finished = False # ADDED: Workflow finished flag
self._state_lock = asyncio.Lock() # Async lock for shared state protection
@@ -233,7 +238,7 @@ class Process:
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
task.description = task.description.split('Input data from previous tasks:')[0].strip()
while fallback_attempts < Process.DEFAULT_RETRY_LIMIT and not temp_current_task:
while fallback_attempts < self.max_retries and not temp_current_task:
fallback_attempts += 1
logging.debug(f"Fallback attempt {fallback_attempts}: Trying to find next 'not started' task.")
for task_candidate in self.tasks.values():
@@ -248,13 +253,13 @@ class Process:
if not leads_to_task and not task_candidate.next_tasks:
continue # Skip if no valid path exists
if self.task_retry_counter.get(task_candidate.id, 0) < Process.DEFAULT_RETRY_LIMIT:
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
temp_current_task = task_candidate
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
return temp_current_task # Return the found task immediately
else:
logging.debug(f"Max retries reached for task {task_candidate.name} in fallback mode, marking as failed.")
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
task_candidate.status = "failed"
if not temp_current_task:
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
@@ -383,6 +388,7 @@ class Process:
"""Async version of workflow method"""
logging.debug("=== Starting Async Workflow ===")
current_iter = 0 # Track how many times we've looped
workflow_start = time.monotonic() # For timeout enforcement
# Build workflow relationships first
logging.debug("Building workflow relationships...")
for task in self.tasks.values():
@@ -418,6 +424,13 @@ class Process:
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
break
# Enforce workflow timeout if set
if self.workflow_timeout is not None:
elapsed = time.monotonic() - workflow_start
if elapsed > self.workflow_timeout:
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
break
# ADDED: Check workflow finished flag at the start of each cycle
if self.workflow_finished:
logging.info("Workflow finished early as all tasks are completed.")
@@ -455,11 +468,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
Description length: {len(current_task.description)}
""")
# Add context from previous tasks to description
# Build context and set description for this execution pass only
context = self._build_task_context(current_task)
if context:
# Update task description with context
current_task.description = current_task.description + context
# Store original description if not already stored
if not hasattr(current_task, '_original_description'):
current_task._original_description = current_task.description
# Set description with context for execution; reset after yield to prevent accumulation
current_task.description = current_task._original_description + (context if context else "")
# Skip execution for loop tasks, only process their subtasks
if current_task.task_type == "loop":
@@ -566,6 +581,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -577,28 +595,29 @@ Subtask: {st.name}
current_task = None
break
# Reset completed task to "not started" so it can run again
if self.tasks[task_id].status == "completed":
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
subtask_name = self.tasks[task_id].name
task_to_check = self.tasks[task_id]
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
logging.debug(f"Task type: {task_to_check.task_type}")
logging.debug(f"Task status before reset check: {task_to_check.status}")
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
# Reset completed task to "not started" so it can run again (atomic operation)
async with self._state_lock:
if self.tasks[task_id].status == "completed":
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
subtask_name = self.tasks[task_id].name
task_to_check = self.tasks[task_id]
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
logging.debug(f"Task type: {task_to_check.task_type}")
logging.debug(f"Task status before reset check: {task_to_check.status}")
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
for t in self.tasks.values()) and
not task_to_check.async_execution): # Don't reset async parallel tasks
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
self.tasks[task_id].status = "not started"
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
else:
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
for t in self.tasks.values()) and
not task_to_check.async_execution): # Don't reset async parallel tasks
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
self.tasks[task_id].status = "not started"
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
else:
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
# Handle loop progression
if current_task.task_type == "loop":
@@ -1116,11 +1135,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
Description length: {len(current_task.description)}
""")
# Add context from previous tasks to description
# Build context and set description for this execution pass only
context = self._build_task_context(current_task)
if context:
# Update task description with context
current_task.description = current_task.description + context
# Store original description if not already stored
if not hasattr(current_task, '_original_description'):
current_task._original_description = current_task.description
# Set description with context for execution; reset after yield to prevent accumulation
current_task.description = current_task._original_description + (context if context else "")
# Skip execution for loop tasks, only process their subtasks
if current_task.task_type == "loop":
@@ -1227,6 +1248,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -640,6 +640,7 @@ class MinimalTelemetry:
# Global telemetry instance
_telemetry_instance = None
_telemetry_instance_lock = threading.Lock()
def get_telemetry() -> MinimalTelemetry:
"""
@@ -649,17 +650,19 @@ def get_telemetry() -> MinimalTelemetry:
The global MinimalTelemetry instance
"""
global _telemetry_instance
if _telemetry_instance is None:
_telemetry_instance = MinimalTelemetry()
return _telemetry_instance
with _telemetry_instance_lock:
if _telemetry_instance is None:
_telemetry_instance = MinimalTelemetry()
return _telemetry_instance
def disable_telemetry():
"""Programmatically disable telemetry."""
global _telemetry_instance
if _telemetry_instance:
_telemetry_instance.enabled = False
else:
_telemetry_instance = MinimalTelemetry(enabled=False)
with _telemetry_instance_lock:
if _telemetry_instance:
_telemetry_instance.enabled = False
else:
_telemetry_instance = MinimalTelemetry(enabled=False)
def force_shutdown_telemetry():
"""
@@ -667,8 +670,9 @@ def force_shutdown_telemetry():
This function ensures proper termination of all background threads.
"""
global _telemetry_instance
if _telemetry_instance:
_telemetry_instance.shutdown()
with _telemetry_instance_lock:
if _telemetry_instance:
_telemetry_instance.shutdown()
# Additional cleanup - wait for all threads to finish
import threading
@@ -698,11 +702,12 @@ def force_shutdown_telemetry():
def enable_telemetry():
"""Programmatically enable telemetry (if not disabled by environment)."""
global _telemetry_instance
if not _is_telemetry_disabled():
if _telemetry_instance:
_telemetry_instance.enabled = True
else:
_telemetry_instance = MinimalTelemetry(enabled=True)
with _telemetry_instance_lock:
if not _is_telemetry_disabled():
if _telemetry_instance:
_telemetry_instance.enabled = True
else:
_telemetry_instance = MinimalTelemetry(enabled=True)
# For backward compatibility with existing code
class TelemetryCollector:
@@ -200,7 +200,16 @@ TOOL_MAPPINGS = {
'email_tools': ('.email_tools', None),
}
_instances = {} # Cache for class instances (protected by _tools_lock)
# Tool factory functions - caches classes but creates fresh instances
# This prevents state leakage between concurrent agents while optimizing import performance
_loaded_classes = {} # Cache the Class, NOT the instance
def _create_tool_instance(class_name: str, module_path: str):
"""Create a new tool instance. Caches the class but returns fresh instances to prevent state sharing."""
if class_name not in _loaded_classes:
module = import_module(module_path, __package__)
_loaded_classes[class_name] = getattr(module, class_name)
return _loaded_classes[class_name]() # Fresh instance safe for multi-agent
# Profile exports (lazy loaded)
_PROFILE_EXPORTS = frozenset({
@@ -325,17 +334,10 @@ def __getattr__(name: str) -> Any:
return module # Returns the callable module
return getattr(module, name)
else:
# Class method import (thread-safe)
if class_name not in _instances:
with _tools_lock:
# Double-check pattern to avoid race conditions
if class_name not in _instances:
module = import_module(module_path, __package__)
class_ = getattr(module, class_name)
_instances[class_name] = class_()
# Get the method and bind it to the instance
method = getattr(_instances[class_name], name)
# Create a fresh tool instance for each agent/session to prevent state leakage
# This factory pattern ensures multi-agent safety by avoiding shared mutable state
instance = _create_tool_instance(class_name, module_path)
method = getattr(instance, name)
return method
__all__ = list(TOOL_MAPPINGS.keys()) + [
@@ -434,8 +434,18 @@ class CircuitBreakerRegistry:
self._breakers.clear()
# Global registry instance for convenience
_global_registry = CircuitBreakerRegistry()
# Global registry instance for convenience (lazy initialization)
_global_registry = None
_global_registry_lock = threading.Lock()
def _get_global_registry() -> CircuitBreakerRegistry:
"""Get or create the global circuit breaker registry."""
global _global_registry
with _global_registry_lock:
if _global_registry is None:
_global_registry = CircuitBreakerRegistry()
return _global_registry
def get_circuit_breaker(
@@ -455,14 +465,14 @@ def get_circuit_breaker(
Returns:
CircuitBreaker instance
"""
return _global_registry.get_or_create(name, config, health_check, fallback)
return _get_global_registry().get_or_create(name, config, health_check, fallback)
def get_all_circuit_breaker_stats() -> Dict[str, Dict[str, Any]]:
"""Get statistics for all circuit breakers in the global registry."""
return _global_registry.get_all_stats()
return _get_global_registry().get_all_stats()
def reset_all_circuit_breakers() -> None:
"""Reset all circuit breakers in the global registry."""
_global_registry.reset_all()
_get_global_registry().reset_all()
@@ -39,17 +39,20 @@ class FileTools:
if filepath.startswith('~'):
filepath = os.path.expanduser(filepath)
# Normalize the path
normalized = os.path.normpath(filepath)
absolute = os.path.abspath(normalized)
# Check for path traversal attempts (.. after normalization)
# Check for path traversal attempts BEFORE normalization
# We check the original input for '..' to catch traversal attempts
if '..' in normalized:
if '..' in filepath:
raise ValueError(f"Path traversal detected: {filepath}")
# Additional check: ensure the resolved path doesn't escape expected boundaries
# This is a basic check - in production, you'd want to define allowed directories
# Normalize the path and securely resolve symlinks
normalized = os.path.normpath(filepath)
absolute = os.path.realpath(normalized)
# Prevent path traversal outside current workspace / allowed directories
cwd = os.getcwd()
if not absolute.startswith(cwd):
raise ValueError(f"Path traversal detected: {filepath} escapes workspace {cwd}")
return absolute
@staticmethod
@@ -327,7 +330,7 @@ class FileTools:
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
# Download file
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
with httpx.stream("GET", url, timeout=timeout, follow_redirects=False) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
@@ -455,9 +455,14 @@ def get_circuit_breaker_dashboard_data() -> Dict[str, Any]:
return dashboard_data
# Global health monitor instance for convenience
_global_health_monitor = HealthMonitor()
# Global health monitor instance for convenience (lazy initialization)
_global_health_monitor = None
_global_health_monitor_lock = threading.Lock()
def get_health_monitor() -> HealthMonitor:
"""Get the global health monitor instance."""
return _global_health_monitor
global _global_health_monitor
with _global_health_monitor_lock:
if _global_health_monitor is None:
_global_health_monitor = HealthMonitor()
return _global_health_monitor
@@ -143,7 +143,15 @@ def safe_execute():
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__'
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
}}
for node in ast.walk(tree):
@@ -162,7 +170,9 @@ def safe_execute():
"success": False
}}
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
return {{
"result": None,
"stdout": "",
@@ -17,6 +17,7 @@ Usage:
"""
import logging
import threading
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -24,6 +25,7 @@ logger = get_logger(__name__)
# ── lazy singleton store ─────────────────────────────────────────────────────
_store_instance = None
_store_instance_lock = threading.Lock()
def set_store(store):
"""Inject an external schedule store (e.g., config.yaml-backed).
@@ -33,7 +35,8 @@ def set_store(store):
``config.yaml`` instead of the default ``jobs.json``.
"""
global _store_instance
_store_instance = store
with _store_instance_lock:
_store_instance = store
def _get_store():
"""Return (or create) the global schedule store.
@@ -42,11 +45,12 @@ def _get_store():
Automatically migrates any existing ``jobs.json`` data on first use.
"""
global _store_instance
if _store_instance is None:
from ..scheduler.config_store import ConfigYamlScheduleStore
_store_instance = ConfigYamlScheduleStore()
_store_instance.migrate_from_json()
return _store_instance
with _store_instance_lock:
if _store_instance is None:
from ..scheduler.config_store import ConfigYamlScheduleStore
_store_instance = ConfigYamlScheduleStore()
_store_instance.migrate_from_json()
return _store_instance
# ── tools ────────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@ Schema Version: 1.0
import json
import time
import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@@ -388,17 +389,20 @@ class TraceEmitter:
# Global default emitter (NoOp by default)
_default_emitter: Optional[TraceEmitter] = None
_default_emitter_lock = threading.Lock()
def get_default_emitter() -> TraceEmitter:
"""Get the default trace emitter."""
global _default_emitter
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
with _default_emitter_lock:
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
def set_default_emitter(emitter: TraceEmitter) -> None:
"""Set the default trace emitter."""
global _default_emitter
_default_emitter = emitter
with _default_emitter_lock:
_default_emitter = emitter
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.112"
version = "1.5.114"
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.114"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-01",
"lastUpdated": "2026-04-07",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-01",
"lastUpdated": "2026-04-07",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -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-07
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+38 -4
View File
@@ -4,6 +4,7 @@
*/
import { AgentFlow, Task, TaskConfig } from './index';
import * as path from 'path';
export interface YAMLWorkflowDefinition {
name: string;
@@ -39,8 +40,9 @@ export interface ParsedWorkflow {
* Parse YAML string into workflow definition
*/
export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
// Simple YAML parser for workflow definitions
// For production, use js-yaml package
// SECURITY: If migrating to js-yaml, you MUST use:
// yaml.load(content, { schema: yaml.JSON_SCHEMA })
// Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution.
const lines = yamlContent.split('\n');
const result: YAMLWorkflowDefinition = {
name: '',
@@ -82,7 +84,15 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
type: 'agent'
};
} else if (currentStep) {
// Step properties
// Step properties — whitelist allowed keys to prevent injection
const ALLOWED_STEP_KEYS = new Set([
'type', 'agent', 'tool', 'input', 'output', 'condition',
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
]);
if (!ALLOWED_STEP_KEYS.has(key)) {
// Ignore unknown keys — do not allow arbitrary property injection
continue;
}
if (key === 'type') currentStep.type = value as any;
else if (key === 'agent') currentStep.agent = value;
else if (key === 'tool') currentStep.tool = value;
@@ -265,9 +275,33 @@ function parseValue(value: string): any {
export async function loadWorkflowFromFile(
filePath: string,
agents: Record<string, any> = {},
tools: Record<string, any> = {}
tools: Record<string, any> = {},
options: { basePath?: string; maxFileSizeBytes?: number } = {}
): Promise<ParsedWorkflow> {
const fs = await import('fs/promises');
// SECURITY: Prevent path traversal
const normalizedPath = path.normalize(filePath);
if (normalizedPath.includes('..')) {
throw new Error('Path traversal detected: ".." is not allowed in file paths');
}
// If basePath is specified, ensure resolvedPath stays within it
if (options.basePath) {
const resolvedBase = path.resolve(options.basePath);
const resolvedFile = path.resolve(options.basePath, normalizedPath);
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
throw new Error(`File path must be within base directory: ${options.basePath}`);
}
}
// SECURITY: Enforce file size limit (default 1 MB)
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
const stat = await fs.stat(filePath);
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}
const content = await fs.readFile(filePath, 'utf-8');
const definition = parseYAMLWorkflow(content);
return createWorkflowFromYAML(definition, agents, tools);
+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.114.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.114.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}"}
@@ -15,9 +15,70 @@ if TYPE_CHECKING:
from .code_intelligence import CodeIntelligenceRouter
from .action_orchestrator import ActionOrchestrator
import os
import re
logger = logging.getLogger(__name__)
def _sanitize_filepath(filepath: str, workspace: str = None) -> str:
"""Validate and sanitize a filepath against injection attacks.
Raises ValueError if the path is unsafe.
"""
# Reject null bytes (common injection technique)
if '\x00' in filepath:
raise ValueError("Null bytes are not allowed in file paths")
# Reject obvious shell meta-characters in file names
if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')):
raise ValueError(f"Unsafe characters in filepath: {filepath!r}")
# Normalize and reject path traversal
normalized = os.path.normpath(filepath)
if '..' in normalized.split(os.sep):
raise ValueError(f"Path traversal detected in: {filepath!r}")
# If we have a workspace, ensure the resolved path stays within it
if workspace:
resolved = os.path.realpath(os.path.join(workspace, normalized))
ws_resolved = os.path.realpath(workspace)
if not resolved.startswith(ws_resolved + os.sep) and resolved != ws_resolved:
raise ValueError(
f"Path {filepath!r} resolves outside workspace {workspace!r}"
)
return normalized
def _sanitize_command(command: str) -> str:
"""Basic command sanitization — reject common injection vectors.
The command still goes through ACP approval flow, but this prevents
the most egregious prompt-injection-to-shell-injection chains.
Raises ValueError if dangerous patterns are detected.
"""
if '\x00' in command:
raise ValueError("Null bytes are not allowed in commands")
# Reject command chaining operators that indicate injection
DANGEROUS_PATTERNS = [
'$(', '`', # Command substitution
'&&', '||', # Command chaining
'>>', '> ', # Output redirection
'| ', # Pipe
]
for pattern in DANGEROUS_PATTERNS:
if pattern in command:
raise ValueError(
f"Potentially unsafe command pattern detected: {pattern!r} "
f"in command: {command!r}. Use separate commands instead."
)
return command
def create_agent_centric_tools(
runtime: "InteractiveRuntime",
router: "CodeIntelligenceRouter" = None,
@@ -77,8 +138,17 @@ def create_agent_centric_tools(
JSON string with result including plan status and verification
"""
async def _create():
# Validate filepath
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
# Build a detailed prompt for the orchestrator
prompt = f"create file {filepath}"
prompt = f"create file {safe_path}"
# Create plan
result = await orchestrator.create_plan(prompt)
@@ -141,7 +211,15 @@ def create_agent_centric_tools(
JSON string with result including plan status
"""
async def _edit():
prompt = f"edit file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"edit file {safe_path}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -189,7 +267,15 @@ def create_agent_centric_tools(
JSON string with result
"""
async def _delete():
prompt = f"delete file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"delete file {safe_path}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -234,7 +320,12 @@ def create_agent_centric_tools(
JSON string with command output
"""
async def _execute():
prompt = f"run command: {command}"
try:
safe_cmd = _sanitize_command(command)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"run command: {safe_cmd}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -414,10 +505,16 @@ def create_agent_centric_tools(
if not path.is_absolute():
path = Path(runtime.config.workspace) / filepath
if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
return json.dumps({"error": f"Path escapes workspace: {filepath}"})
if not resolved.exists():
return json.dumps({"error": f"File not found: {filepath}"})
content = path.read_text()
content = resolved.read_text()
# Track in trace if enabled
if runtime._trace:
@@ -451,7 +548,13 @@ def create_agent_centric_tools(
if not path.is_absolute():
path = Path(runtime.config.workspace) / directory
if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
return json.dumps({"error": f"Directory escapes workspace: {directory}"})
if not resolved.exists():
return json.dumps({"error": f"Directory not found: {directory}"})
files = []
@@ -30,15 +30,24 @@ def resolve_approval_backend(value: Optional[str]) -> Optional[Any]:
Raises:
ValueError: If the value is not a recognised backend name.
"""
if value is None or value.lower() == "none":
if value is None:
return None
if isinstance(value, bool):
value = "true" if value else "false"
if str(value).lower() == "none":
return None
name = value.lower().strip()
name = str(value).lower().strip()
if name in ("true", "console"):
if name in ("true", "console", "yes", "1"):
from praisonaiagents.approval.backends import ConsoleBackend
return ConsoleBackend()
if name in ("false", "no", "0"):
return None
if name == "auto":
from praisonaiagents.approval.backends import AutoApproveBackend
return AutoApproveBackend()
@@ -847,8 +847,14 @@ class TemplatesHandler:
zip_path = Path(tmpdir) / "repo.zip"
urllib.request.urlretrieve(zip_url, zip_path)
# Extract
# Extract safely, preventing Zip Slip
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
import os
tmpdir_real = os.path.realpath(tmpdir)
for zip_info in zip_ref.infolist():
extracted_path = os.path.realpath(os.path.join(tmpdir_real, zip_info.filename))
if not extracted_path.startswith(tmpdir_real):
raise Exception(f"Attempted Zip Slip vulnerabilities detected: {zip_info.filename}")
zip_ref.extractall(tmpdir)
# Find the template
+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.114 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -7,6 +7,7 @@ Provides SSE-based event streaming for agent-to-user communication.
import asyncio
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -240,8 +241,36 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
except ImportError:
raise ImportError("Starlette or FastAPI required for A2U routes")
def _authenticate_request(request) -> Optional[JSONResponse]:
"""Check bearer token auth when A2U_AUTH_TOKEN is configured.
Returns None if authenticated, or a 401/403 JSONResponse otherwise.
"""
auth_token = os.environ.get("A2U_AUTH_TOKEN")
if not auth_token:
# No token configured — auth disabled (development mode)
return None
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
{"error": "Authentication required. Set Authorization: Bearer <token>"},
status_code=401,
)
import hmac
provided = auth_header[7:] # strip "Bearer "
if not hmac.compare_digest(provided, auth_token):
return JSONResponse(
{"error": "Invalid authentication token"},
status_code=403,
)
async def a2u_info(request):
"""GET /a2u/info - Get A2U server info."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
return JSONResponse({
"name": "A2U Event Stream",
"version": "1.0.0",
@@ -258,6 +287,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_subscribe(request):
"""POST /a2u/subscribe - Subscribe to an event stream."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
try:
body = await request.json()
except Exception:
@@ -279,6 +311,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_unsubscribe(request):
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
try:
body = await request.json()
except Exception:
@@ -295,6 +330,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_events_stream(request):
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
stream_name = request.path_params.get("stream_name", "events")
# Create subscription for this stream
@@ -319,6 +357,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_events_subscription(request):
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
subscription_id = request.path_params.get("subscription_id")
if subscription_id not in bus._subscriptions:
@@ -371,9 +371,11 @@ class OAuthManager:
Returns:
True if valid
"""
import hmac
# Check if we have this token stored
for stored_token in self._tokens.values():
if stored_token.access_token == token:
# Use hmac.compare_digest to prevent timing attacks
if hmac.compare_digest(stored_token.access_token, token):
return not stored_token.is_expired()
# Token not found — deny by default
@@ -0,0 +1,24 @@
"""
PraisonAI Observability Integrations.
Observability adapters for external platforms like Langfuse, WandB, etc.
These live in the wrapper layer and implement TraceSinkProtocol from the core SDK.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .langfuse import LangfuseSink, LangfuseSinkConfig
__all__ = []
def __getattr__(name: str):
"""Lazy imports for optional observability dependencies."""
if name == "LangfuseSink":
from .langfuse import LangfuseSink
return LangfuseSink
elif name == "LangfuseSinkConfig":
from .langfuse import LangfuseSinkConfig
return LangfuseSinkConfig
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,294 @@
"""
Langfuse TraceSinkProtocol Implementation for PraisonAI.
Provides LangfuseSink adapter that implements TraceSinkProtocol from the core SDK,
enabling observability integration with Langfuse for agent traces, tool calls, and errors.
Architecture:
- Core SDK (praisonaiagents): Defines TraceSinkProtocol
- Wrapper (praisonai): Implements LangfuseSink adapter (this file)
- Pattern: Protocol-driven design per AGENTS.md §4.1
"""
import json
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol
@dataclass
class LangfuseSinkConfig:
"""
Configuration for Langfuse trace sink.
Follows the False=disabled, True=defaults, Config=custom pattern from AGENTS.md §5.2.
Fields not explicitly provided are filled from environment variables:
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST (or LANGFUSE_BASE_URL).
"""
public_key: str = ""
secret_key: str = ""
host: str = ""
flush_at: int = 20
flush_interval: float = 10.0
enabled: bool = True
def __post_init__(self):
"""Fill missing fields from environment variables."""
import os
if not self.public_key:
self.public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "")
if not self.secret_key:
self.secret_key = os.getenv("LANGFUSE_SECRET_KEY", "")
if not self.host:
self.host = os.getenv(
"LANGFUSE_HOST",
os.getenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com"),
)
class LangfuseSink:
"""
TraceSinkProtocol implementation for Langfuse.
Maps PraisonAI ActionEvents to Langfuse v3+ observation API:
- AGENT_START -> start_observation(as_type="span") for trace and root span
- AGENT_END -> observation.update() + observation.end()
- TOOL_START -> start_observation(as_type="span") for child span
- TOOL_END -> observation.update() + observation.end()
- ERROR -> start_observation(as_type="event", level="ERROR")
- OUTPUT -> start_observation(as_type="event")
Thread-safe: langfuse.Langfuse handles its own batching and thread safety.
"""
__slots__ = ("_config", "_client", "_traces", "_spans", "_lock", "_closed")
def __init__(self, config: Optional[LangfuseSinkConfig] = None):
self._config = config or LangfuseSinkConfig()
self._client: Optional[Any] = None # Lazy-loaded langfuse.Langfuse
self._traces: Dict[str, Any] = {} # agent_name -> trace observation
self._spans: Dict[str, Any] = {} # span_key -> span observation
self._lock = threading.Lock()
self._closed = False
if self._config.enabled:
self._initialize_client()
def _initialize_client(self) -> None:
"""Lazy import and initialize Langfuse client."""
try:
import langfuse
# Validate config
if not (self._config.public_key and self._config.secret_key):
raise ValueError(
"Langfuse credentials missing. Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY "
"environment variables or pass them to LangfuseSinkConfig."
)
self._client = langfuse.Langfuse(
public_key=self._config.public_key,
secret_key=self._config.secret_key,
host=self._config.host,
flush_at=self._config.flush_at,
flush_interval=self._config.flush_interval,
)
except ImportError:
raise ImportError(
"Langfuse is not installed. Install with: pip install praisonai[langfuse]"
)
def emit(self, event: ActionEvent) -> None:
"""Emit an action event to Langfuse."""
if not self._config.enabled or self._closed or not self._client:
return
try:
with self._lock:
self._handle_event(event)
except Exception as e:
# Don't let observability errors break agent execution
print(f"LangfuseSink error: {e}")
def _handle_event(self, event: ActionEvent) -> None:
"""Handle a single ActionEvent (called under lock)."""
event_type = event.event_type
agent_name = event.agent_name or "unknown-agent"
if event_type == ActionEventType.AGENT_START.value:
self._handle_agent_start(event, agent_name)
elif event_type == ActionEventType.AGENT_END.value:
self._handle_agent_end(event, agent_name)
elif event_type == ActionEventType.TOOL_START.value:
self._handle_tool_start(event, agent_name)
elif event_type == ActionEventType.TOOL_END.value:
self._handle_tool_end(event, agent_name)
elif event_type == ActionEventType.ERROR.value:
self._handle_error(event, agent_name)
elif event_type == ActionEventType.OUTPUT.value:
self._handle_output(event, agent_name)
def _handle_agent_start(self, event: ActionEvent, agent_name: str) -> None:
"""Handle AGENT_START -> create trace observation and root span."""
# Create root span observation (trace is implicit in v3+)
trace_name = f"agent-run-{agent_name}"
trace_input = event.metadata.get("input") if event.metadata else None
# Use unique agent key combining agent_id and name for collision safety
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
span = self._client.start_observation(
name=trace_name,
as_type="span",
input=trace_input,
start_time=event.timestamp,
metadata={
"agent_id": event.agent_id,
"agent_name": agent_name,
"schema_version": event.schema_version,
**(event.metadata if event.metadata else {}),
}
)
# Store both trace and span reference with unique key
self._traces[agent_key] = span # Root span serves as trace reference
self._spans[agent_key] = span
def _handle_agent_end(self, event: ActionEvent, agent_name: str) -> None:
"""Handle AGENT_END -> end root span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
span = self._spans.get(agent_key)
if span:
span.update(
end_time=event.timestamp,
output=event.metadata.get("output") if event.metadata else None,
status_message=event.status or "completed",
level="ERROR" if event.status == "error" else "DEFAULT",
)
span.end()
# Clean up
self._spans.pop(agent_key, None)
self._traces.pop(agent_key, None)
def _handle_tool_start(self, event: ActionEvent, agent_name: str) -> None:
"""Handle TOOL_START -> create child span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
parent_span = self._spans.get(agent_key)
if not parent_span:
return
tool_name = event.tool_name or "unknown-tool"
# Generate unique tool invocation key with UUID for collision safety
import uuid
tool_invocation_id = str(uuid.uuid4())[:8] # Short UUID
tool_key = f"{agent_key}:{tool_name}:{tool_invocation_id}"
tool_span = self._client.start_observation(
name=tool_name,
as_type="span",
start_time=event.timestamp,
input=event.tool_args,
metadata={
"tool_name": tool_name,
"parent_agent": agent_name,
"invocation_id": tool_invocation_id,
**(event.metadata if event.metadata else {}),
}
)
# Store with unique tool key
self._spans[tool_key] = tool_span
def _handle_tool_end(self, event: ActionEvent, agent_name: str) -> None:
"""Handle TOOL_END -> end tool span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
tool_name = event.tool_name or "unknown-tool"
# Find the most recent matching tool span
tool_key = None
for key in self._spans:
if key.startswith(f"{agent_key}:{tool_name}:") and key != agent_key:
tool_key = key
if not tool_key:
return
tool_span = self._spans.pop(tool_key, None)
if tool_span:
tool_span.update(
end_time=event.timestamp,
output=event.tool_result_summary,
status_message=event.status or "completed",
level="ERROR" if event.status == "error" else "DEFAULT",
metadata={
"duration_ms": event.duration_ms,
**(event.metadata if event.metadata else {}),
}
)
tool_span.end()
def _handle_error(self, event: ActionEvent, agent_name: str) -> None:
"""Handle ERROR -> create error event observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
error_event = self._client.start_observation(
name="error",
as_type="event",
start_time=event.timestamp,
level="ERROR",
status_message=event.error_message,
input=event.tool_args,
metadata={
"tool_name": event.tool_name,
"agent_name": agent_name,
**(event.metadata if event.metadata else {}),
}
)
error_event.end()
def _handle_output(self, event: ActionEvent, agent_name: str) -> None:
"""Handle OUTPUT -> create output event observation."""
output_event = self._client.start_observation(
name="output",
as_type="event",
start_time=event.timestamp,
output=event.tool_result_summary,
metadata={
"agent_name": agent_name,
**(event.metadata or {}),
}
)
output_event.end()
def flush(self) -> None:
"""Flush any buffered events."""
if self._client and not self._closed:
try:
self._client.flush()
except Exception as e:
print(f"LangfuseSink flush error: {e}")
def close(self) -> None:
"""Close the sink and release resources."""
if not self._closed:
# Flush before marking closed, so flush() guard passes
self.flush()
self._closed = True
if self._client:
try:
# Close any remaining spans
with self._lock:
for span in self._spans.values():
try:
span.end()
except Exception:
pass
self._spans.clear()
self._traces.clear()
except Exception:
pass
+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.114"
+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.114",
"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.114"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },