Compare commits

...
Author SHA1 Message Date
MervinPraison 9b7f458b35 Release v4.5.117 2026-04-08 11:52:17 +01:00
MervinPraison 83f7babd89 docs: auto-update feature parity trackers [skip ci] 2026-04-08 10:51:07 +00:00
MervinPraison 0d65b5aaf9 feat(plugins): add entry point discovery support for plugins
Implemented automatic discovery and loading of protocol-driven plugins
via pip in the `PluginManager`. The new method `discover_entry_points`
fetches entry points from the `praisonai.plugins` group, loads callable
plugins, and registers them. Updated version number to 1.5.117.
2026-04-08 11:50:49 +01:00
Mervin Praison 22e1e0bdb0 Merge pull request #1303 from MervinPraison/claude/issue-1302-20260408-0931
fix: review feedback on protocol-driven architecture — bugs, DRY, tests
2026-04-08 11:19:14 +01:00
praisonai-triage-agent[bot]andMervin Praison cc5683c5c4 fix: address all valid reviewer feedback on protocol-driven architecture
- Fix registry locking issues: hold lock only for lookup, not construction
- Fix hardcoded provider detection: replace with true registration API
- Add canonical naming aliases per AGENTS.md: add_*/has_* functions
- Fix knowledge adapter defaults: use actual available adapters
- Fix asyncio.run() documentation: remove problematic streaming guidance
- Fix memory leak: defensive copy in get_all_memories()
- Rename LLMProviderAdapter -> LLMProviderProtocol per conventions

Addresses feedback from Qodo, CodeRabbit, Gemini reviewers.
Maintains backward compatibility via wrapper functions.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-04-08 09:56:54 +00:00
copilot-swe-agent[bot]andMervinPraison d4e82d3909 fix: address review feedback on protocol-driven architecture
- Fix GeminiAdapter tool names to match GEMINI_INTERNAL_TOOLS constants
- Fix OllamaAdapter threshold to match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD = 1
- Extract generic AdapterRegistry[T] base class to eliminate DRY violation
- Add debug logging to registry exception handling (no longer silent)
- Move InMemoryAdapter from __init__.py to its own in_memory_adapter.py module
- Add 38 unit tests covering all new adapter and registry code paths"

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0fc360e2-23ac-43a1-8a56-50dd4686feb1

Co-authored-by: MervinPraison <[email protected]>
2026-04-08 09:52:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 35146cd1c8 demo: add concrete LLM provider adapters to demonstrate protocol approach
Shows how to replace scattered provider dispatch in llm.py with clean adapters:

- DefaultAdapter: Base implementation with sensible fallbacks
- OllamaAdapter: Replaces OLLAMA_SUMMARY_ITERATION_THRESHOLD logic
- AnthropicAdapter: Handles Claude prompt caching capabilities
- GeminiAdapter: Handles gemini_internal_tools formatting
- get_provider_adapter(): Replaces model.startswith() branching

This demonstrates the systematic approach for Gap 2 - extracting the 40+
provider branches from core into clean, testable adapters.

Co-authored-by: MervinPraison <[email protected]>
2026-04-08 09:38:04 +00:00
praisonai-triage-agent[bot]andMervinPraison 3ad181950c feat: implement protocol-driven architecture for LLM and memory adapters
This addresses architectural gaps #1-3 by implementing:

1. LLMProviderAdapter protocol to replace scattered provider dispatch
2. UnifiedLLMProtocol to consolidate dual execution paths
3. Memory and Knowledge adapter registries for protocol-driven backends

Changes:
- Add LLMProviderAdapter and UnifiedLLMProtocol to llm/protocols.py
- Create memory/adapters/registry.py with thread-safe adapter registration
- Create knowledge/adapters/registry.py with thread-safe adapter registration
- Register core adapters (sqlite, in_memory) in core SDK
- Heavy implementations (chromadb, mem0) to be moved to wrapper

Follows AGENTS.md protocol-driven core principle. Backward compatible.

Co-authored-by: MervinPraison <[email protected]>
2026-04-08 09:36:45 +00:00
MervinPraison 09970cd348 docs: auto-update feature parity trackers [skip ci] 2026-04-08 08:10:23 +00:00
Mervin Praison 387b81d324 Merge pull request #1297 from MervinPraison/claude/issue-1288-20260408-0723
fix: resolve three architectural gaps (DRY, error protocol, streaming parity)
2026-04-08 09:10:07 +01:00
Mervin Praison ae1cf98fc9 Merge pull request #1294 from MervinPraison/claude/issue-1289-20260408-0723
fix: Critical async safety issues (Gap 2 from #1289)
2026-04-08 09:01:01 +01:00
MervinPraison 52354bf3f4 docs: auto-update feature parity trackers [skip ci] 2026-04-08 08:00:01 +00:00
Mervin Praison 5bd317178a Merge pull request #1295 from MervinPraison/claude/issue-1290-20260408-0723
feat: unify Handoff and SubagentDelegator into single execution engine
2026-04-08 08:59:42 +01:00
Mervin Praison 94bc2ce611 Merge pull request #1296 from MervinPraison/claude/issue-1293-20260408-0723
feat: Implement PraisonAI-Plugins system with entry_points discovery
2026-04-08 08:57:43 +01:00
praisonai-triage-agent[bot]andMervin Praison 55287d4cc3 fix: address reviewer feedback - backward compatibility and bugs
- Add missing BudgetExceededError import in chat_mixin.py
- Fix budget_type from 'tokens' to 'cost' for USD values
- Add backward compatibility constructor for BudgetExceededError
- Add legacy attributes (agent_name, total_cost, max_budget) for compatibility
- Fix HandoffTimeoutError constructor call in handoff.py
- Add backward compatibility aliases for handoff errors
- Fix streaming precedence logic to handle explicit false values
- Add explicit BudgetExceededError re-raise to prevent generic exception swallowing

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:56:55 +00:00
praisonai-triage-agent[bot]andMervin Praison a8b72b046f fix: address all code review issues in plugin entry_points discovery
- Fix entry points never discovered in main flow (plugins.enable now calls discover_entry_points)
- Fix fragile entry_points fallback using robust try/except pattern from ToolRegistry
- Fix non-deterministic tests by mocking importlib.metadata properly
- Fix overbroad import monkeypatch to only affect importlib.metadata
- Add thread safety with proper _lock usage in register() and discover_entry_points()
- Add positive test case for successful entry point loading and registration

All reviewer feedback from Qodo, Gemini, CodeRabbit addressed.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:50:20 +00:00
praisonai-triage-agent[bot]andMervin Praison 69fce71ad5 fix: address critical bugs and architectural issues in handoff unification
- Fix HandoffConfig.max_concurrent default from 3 to 5 per issue requirements
- Fix deadlock bug: parallel_handoffs() now treats max_concurrent<=0 as unlimited
- Fix BaseException handling to prevent non-HandoffResult objects in return list
- Export parallel_handoffs in package __init__.py for public API access
- Remove broken documentation URL from deprecation warning
- Wire allow_parallel flag to control parallel_handoffs() behavior
- Fix unused variable warning by prefixing with underscore

These changes address all critical bugs and API inconsistencies identified
by code reviewers while maintaining backward compatibility.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:47:43 +00:00
praisonai-triage-agent[bot]andMervin Praison e4e2657f17 fix: resolve critical async safety issues identified by code reviewers
- Fix threading.Lock used with async with in Process class (issue #1289)
  * Replace sync lock creation with lazy async lock initialization
  * Prevents runtime AttributeError in aworkflow method
- Improve thread safety in AsyncPostgres lazy lock initialization
  * Add double-checked locking pattern to prevent race conditions
  * Ensures only one asyncio.Lock instance created across coroutines
- Remove redundant asyncio import in async_postgres.py
- Improve exception chaining for better error diagnostics

Addresses critical issues raised by Gemini, Qodo, CodeRabbit reviewers.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:45:43 +00:00
praisonai-triage-agent[bot]andClaude 56443bf3f8 feat: fix three architectural gaps identified in issue #1288
1. DRY violation in AgentTeam execute_task/aexecute_task
   - Extracted shared helpers: _prepare_task_prompt(), _execute_with_agent_sync/async()
   - Fixed missing stream=self.stream parameter in async path
   - Eliminated ~120 lines of duplicated logic

2. Structured exception hierarchy with ErrorContextProtocol
   - Created praisonaiagents/errors.py with protocol-driven error handling
   - Added error categories: tool, llm, budget, validation, network, handoff
   - Migrated existing ad-hoc errors to structured hierarchy
   - Updated lazy imports for backward compatibility

3. Streaming feature parity for YAML/CLI
   - Enhanced YAML schema to support streaming: configuration block
   - Added CLI flags: --stream and --stream-metrics
   - Extended agents_generator.py to wire streaming through OutputConfig
   - Achieves complete CLI + YAML + Python feature parity

Fixes violations of stated pillars: DRY, protocol-driven core, 3-way feature parity.

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

Co-Authored-By: Claude <[email protected]>
2026-04-08 07:35:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 258ab1cd20 feat(plugins): add entry_points discovery to PluginManager
- Add discover_entry_points() method to PluginManager for automatic plugin discovery
- Support pip-installed plugins via 'praisonaiagents.plugins' entry_points group
- Compatible with Python 3.8+ using importlib.metadata
- Graceful error handling for missing dependencies
- Added comprehensive unit tests for entry_points discovery
- Enables external plugin packages like PraisonAI-Plugins to be auto-discovered

Fixes #1293

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:31:37 +00:00
MervinPraison 63248a3a46 ci: add hooks and guardrails to plugin description in claude.yml 2026-04-08 08:29:45 +01:00
praisonai-triage-agent[bot]andMervin Praison 331b47013e feat: unify Handoff and SubagentDelegator into single execution engine
- Extend HandoffConfig with allow_parallel field for parallel execution
- Add parallel_handoffs() function providing SubagentDelegator's parallel capabilities
- Add deprecation warning to SubagentDelegator directing users to handoff system
- Maintains backward compatibility while consolidating overlapping functionality

Fixes #1290

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

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:28:46 +00:00
MervinPraison 162c10c16f ci: expressly differentiate Tools vs Plugins in claude routing 2026-04-08 08:28:02 +01:00
MervinPraisonandMervin Praison cdefa98c09 fix: critical async safety issues - replace sync constructor asyncio.Lock, add postgres init race protection, fix state store save races
Addresses Gap 2 from issue #1289:
1. Replace asyncio.Lock() with threading.Lock() in Process.__init__() - asyncio.Lock in sync constructor fails outside event loop
2. Add async lock protection to async postgres store init() to prevent race conditions creating duplicate connection pools
3. Fix memory state store _maybe_save() race condition where multiple threads could trigger overlapping saves
4. Verified existing thread safety in global error_logs, callbacks already properly protected with locks
5. Verified MemoryMixin already has proper thread safety with _history_lock

These changes ensure multi-agent safety by eliminating race conditions and thread safety issues that could corrupt state or cause crashes when running multiple agents concurrently.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 07:27:22 +00:00
MervinPraison 5b2d924121 ci: add PraisonAI-Plugins to routing instructions in claude workflow 2026-04-08 08:26:46 +01:00
MervinPraison 9fcf1b4acd ci: rewrite Claude workflow STEP 3-ALT to enforce git -C and proper auth headers 2026-04-08 08:23:03 +01:00
MervinPraison 7bec5453d0 test(langfuse_sink): update agent span handling and method assertions
Refactor tests for `LangfuseSinkEmit` to use agent spans with a consistent
identifier format. Update assertions to reflect changes in method
validations for agent classes in `TestApiMdGenerator`. Adjusted several
mock object usages for improved clarity and consistency across tests.
2026-04-08 07:53:49 +01:00
MervinPraison 7c87046753 fix(ci): fix cross-repo auth and identity for Claude Action
Root cause of issue #1283 failure:
1. git config was local, not --global, so identity failed in cloned repos
2. External repos (PraisonAIDocs) were cloned with bare https (no auth)
3. Claude tried to push to main instead of a feature branch

Fix:
- Use --global git config for identity across all repos
- Add STEP 3-ALT with explicit cross-repo workflow using gh repo clone
- gh repo clone auto-configures auth token for push
- Add PraisonAI-Plugins and PraisonAIDocs to routing table
- Explicit instructions to never use bare git clone https://
2026-04-08 07:46:58 +01:00
MervinPraison 81ea4d7706 ci: allow praisonai-triage-agent bot for claude code action 2026-04-08 07:44:38 +01:00
MervinPraison f1d53a409b ci: add cross-repo git configuration to claude workflows 2026-04-08 07:26:54 +01:00
Mervin Praison 384bb06210 Merge pull request #1285 from MervinPraison/claude/pr-1284-20260407-2042
PR #1284: Changes from Claude
2026-04-08 06:54:57 +01:00
MervinPraison 2dd1a80fde docs: auto-update feature parity trackers [skip ci] 2026-04-08 05:48:30 +00:00
Mervin Praison d4a1b6e64b Merge pull request #1286 from MervinPraison/claude/issue-1280-20260407-2043
feat: add missing LLM and retrieval provider protocols
2026-04-08 06:48:13 +01:00
MervinPraisonandClaude 09e85a24d2 security: fix critical path traversal and sandbox escape vulnerabilities
- Resolve merge conflicts with main branch
- Fix getattr sandbox inconsistency by removing getattr from blocked AST calls
- Combine dangerous patterns from both branches for comprehensive protection
- Allow _safe_getattr implementation to work properly in sandbox mode

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

Co-authored-by: Claude <[email protected]>
2026-04-08 05:44:55 +00:00
MervinPraisonandMervin Praison 5990367b3d fix: address code review issues in LLM and RAG protocols
- Fix ModelNotAvailableError to properly pass provider/model to base exception
- Fix RateLimitError type annotation (Optional[str] instead of str = None)
- Fix ContextLengthExceededError to accept and pass provider/model parameters
- Add async aretrieve method to RetrievalStrategyProtocol for async I/O support
- Add missing newline at end of protocols.py file

Addresses review feedback from code reviewers.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-08 05:43:57 +00:00
MervinPraison 01a20e307c refactor(tests): reorganize test agent and tool files into fixtures
Moved test agents and tools to a dedicated fixtures directory. Updated test scripts to reference the new file paths. Removed obsolete test agent and tool files. Improved instructions for running tests.
2026-04-08 06:36:46 +01:00
MervinPraison ed3a689f8c Release v4.5.115 2026-04-07 21:55:08 +01:00
MervinPraison 7313b64544 fix(ci): add Core vs Wrapper vs Tools repo routing to claude prompts
Explicitly tells Claude to clone and open PRs in MervinPraison/PraisonAI-Tools
when a request is for an external tool, integration, or community extension.
2026-04-07 21:50:59 +01:00
MervinPraisonandMervin Praison dacb1dc42c feat: add missing LLM and retrieval provider protocols
Add LLMProviderProtocol, ModelCapabilitiesProtocol, LLMRateLimiterProtocol,
and LLMFailoverProtocol to enable extensibility without vendor lock-in.

Add RetrievalStrategyProtocol to make retrieval strategies pluggable
instead of hardcoded enum values.

Addresses Gap #3 from architectural analysis - missing provider protocols.
Follows AGENTS.md protocol-driven architecture principles.

Co-authored-by: Mervin Praison <[email protected]>
2026-04-07 20:48:55 +00:00
MervinPraisonandMervin Praison 74241c1459 security: fix critical path traversal and sandbox escape vulnerabilities
- Fix path traversal in TypeScript YAML parser: use proper path segment check instead of substring, ensure consistent validation/access paths
- Add missing getattr to Python sandbox blocking to prevent attribute access bypasses
- Enhance command sanitization: add missing pipe, semicolon, newline patterns
- Improve path validation: use robust Path.relative_to() instead of string prefix matching
- Fix type hints and remove unused imports in agent tools

Addresses security issues identified by code reviewers.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-04-07 20:46:16 +00:00
MervinPraison b1048da6a5 refactor: harden input validation and bump to 1.5.115/4.5.115 2026-04-07 21:45:23 +01:00
MervinPraison ffc8cb1bbc fix(ci): DRY consolidation — add issue-triage job for external users in claude.yml
- Added lightweight 'issue-triage' job that runs on ALL issues:opened events
- External users get: welcome comment + automatic label triage (enhancement/bug/question/etc)
- Owner/collaborator issues get: claude label auto-added → triggers code-fix job
- Removed 'allowed_non_write_users' (deprecated in claude-code-action@beta)
- claude-response job now skips 'opened' events (handled by triage job)
- Eliminates need for separate auto-issue-comment.yml intermediary (DRY)
2026-04-07 21:45:23 +01:00
MervinPraison eceaa96141 docs: auto-update feature parity trackers [skip ci] 2026-04-07 20:38:36 +00:00
Mervin Praison e632aadd42 Merge pull request #1284 from MervinPraison/fix/agent-init-type-hints
Fix/agent init type hints
2026-04-07 21:38:17 +01:00
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
88 changed files with 5272 additions and 667 deletions

No files matched your search

+37 -14
View File
@@ -207,21 +207,44 @@ runs:
User Request:
$USER_REQUEST
Please analyze the request and provide helpful assistance. You have access to repository tools and can help with:
- Code analysis and implementation
- GitHub workflows and automation
- Pull request reviews and feedback
- Issue resolution and bug fixes
- Documentation updates
- Testing and deployment
Please analyze the request and provide helpful assistance.
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
1. ALWAYS create a new git branch appropriately named (e.g. \`feature/issue-<number>\`).
2. MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
3. Make your necessary code changes honoring the rules in AGENTS.md.
4. Commit your changes and push the branch to the remote repository.
5. Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request containing your work.
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 1 — READ GUIDELINES:
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- CORE vs WRAPPER vs TOOLS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push the branch
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
Respond naturally and helpfully to the user's request using the available tools.
EOF
+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'
})
+6 -3
View File
@@ -15,15 +15,18 @@ jobs:
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Run Claude Issue Triage
uses: ./.github/actions/claude-issue-triage-action
+155 -15
View File
@@ -11,10 +11,116 @@ on:
types: [submitted]
jobs:
# ================================================================
# Job 1: Lightweight triage for ALL new issues (including external users)
# Posts welcome comment + adds triage labels. No Claude code execution.
# ================================================================
issue-triage:
if: |
github.event_name == 'issues' &&
github.event.action == 'opened' &&
!contains(toJSON(github.event.issue.labels), 'claude')
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Triage and acknowledge issue
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const content = title + ' ' + body;
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
issue.author_association
);
// --- Label triage ---
const labels = [];
// Kind labels
if (content.includes('feature request') || content.includes('[feature')) {
labels.push('enhancement');
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
labels.push('bug');
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
labels.push('question');
}
// Area labels
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
labels.push('security');
}
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
labels.push('performance');
}
if (content.includes('documentation') || content.includes('docs')) {
labels.push('documentation');
}
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
labels.push('javascript');
}
// Apply labels
if (labels.length > 0) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels
});
}
// --- Acknowledgment comment (external users only) ---
if (!isOwner) {
await github.rest.issues.createComment({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: [
`👋 Thanks for opening this issue, @${issue.user.login}!`,
'',
'A maintainer will review this shortly. In the meantime:',
'- Make sure you\'ve included steps to reproduce (for bugs)',
'- Check [existing issues](https://github.com/MervinPraison/PraisonAI/issues) for duplicates',
'- Review the [documentation](https://docs.praison.ai) for related guides',
'',
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
].join('\n')
});
}
// --- For owner: add 'claude' label to trigger the code-fix job ---
if (isOwner) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['claude']
});
}
# ================================================================
# Job 2: Full Claude code-fix (owner/collaborator only)
# Triggers on: labeled 'claude', @claude comments, assignments
# ================================================================
claude-response:
# Allow: human users, github-actions[bot] (auto-comments/labels)
# Block: dependabot, cursor, renovate, other bots
# Skip 'opened' events — triage job handles those above
if: |
github.event.action != 'opened' &&
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
(
@@ -33,58 +139,92 @@ jobs:
actions: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
trigger_phrase: "@claude"
label_trigger: "claude"
allowed_non_write_users: "*"
direct_prompt: |
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
git config --global user.name "MervinPraison"
git config --global user.email "[email protected]"
gh auth setup-git
STEP 1 — READ GUIDELINES:
Read AGENTS.md to understand the architecture rules.
STEP 2 — ARCHITECTURE VALIDATION (MANDATORY before writing code):
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- Does this add heavy implementations to the core SDK (praisonaiagents)? If YES → move it to the wrapper (praisonai) or make it a plugin.
- CORE vs WRAPPER vs TOOLS vs DOCS vs PLUGINS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Capabilities Agents actively call during execution (e.g. SurrealDB tool, Slack tool).
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these checks fail, add a comment to the issue explaining why and close it. Do NOT create a PR.
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
- Create a fix branch and implement a minimal, focused fix
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
a) Clone the repository:
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
b) Copy GitHub authentication from the main repository so push works seamlessly:
git -C /tmp/<REPO_NAME> config http."https://github.com/".extraheader "$(git config --get http."https://github.com/".extraheader)"
c) Create a feature branch (NEVER commit to main):
git -C /tmp/<REPO_NAME> checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
d) Make your changes using absolute paths (e.g. edit /tmp/<REPO_NAME>/docs.json)
e) Commit:
git -C /tmp/<REPO_NAME> add -A
git -C /tmp/<REPO_NAME> commit -m "feat: <description> (fixes #$ISSUE_NUMBER)"
f) Push the branch:
git -C /tmp/<REPO_NAME> push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
g) Create PR in the EXTERNAL repo:
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
<body>"
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push, and create PR using `gh pr create`
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".
NOTE: If you worked on an external repo, the wrapper action will report "No commits" for the main repo at the end. This is expected, do not worry about it.
allowed_tools: |
Bash(git:*)
Bash(python:*)
+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.117" \
"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.117" \
"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.117" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -170,16 +170,17 @@ _LAZY_IMPORTS = {
'Handoff': ('praisonaiagents.agent.handoff', 'Handoff'),
'handoff': ('praisonaiagents.agent.handoff', 'handoff'),
'handoff_filters': ('praisonaiagents.agent.handoff', 'handoff_filters'),
'parallel_handoffs': ('praisonaiagents.agent.handoff', 'parallel_handoffs'),
'RECOMMENDED_PROMPT_PREFIX': ('praisonaiagents.agent.handoff', 'RECOMMENDED_PROMPT_PREFIX'),
'prompt_with_handoff_instructions': ('praisonaiagents.agent.handoff', 'prompt_with_handoff_instructions'),
'HandoffConfig': ('praisonaiagents.agent.handoff', 'HandoffConfig'),
'HandoffResult': ('praisonaiagents.agent.handoff', 'HandoffResult'),
'HandoffInputData': ('praisonaiagents.agent.handoff', 'HandoffInputData'),
'ContextPolicy': ('praisonaiagents.agent.handoff', 'ContextPolicy'),
'HandoffError': ('praisonaiagents.agent.handoff', 'HandoffError'),
'HandoffCycleError': ('praisonaiagents.agent.handoff', 'HandoffCycleError'),
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
'HandoffError': ('praisonaiagents.errors', 'HandoffError'),
'HandoffCycleError': ('praisonaiagents.errors', 'HandoffCycleError'),
'HandoffDepthError': ('praisonaiagents.errors', 'HandoffDepthError'),
'HandoffTimeoutError': ('praisonaiagents.errors', 'HandoffTimeoutError'),
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
@@ -203,7 +204,15 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
# Error hierarchy - structured exception handling
'PraisonAIError': ('praisonaiagents.errors', 'PraisonAIError'),
'ToolExecutionError': ('praisonaiagents.errors', 'ToolExecutionError'),
'LLMError': ('praisonaiagents.errors', 'LLMError'),
'ValidationError': ('praisonaiagents.errors', 'ValidationError'),
'NetworkError': ('praisonaiagents.errors', 'NetworkError'),
'ErrorContextProtocol': ('praisonaiagents.errors', 'ErrorContextProtocol'),
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
@@ -110,7 +110,7 @@ def __getattr__(name):
# Handoff - lightweight
_handoff_names = {
'Handoff', 'handoff', 'handoff_filters',
'Handoff', 'handoff', 'handoff_filters', 'parallel_handoffs',
'RECOMMENDED_PROMPT_PREFIX', 'prompt_with_handoff_instructions',
'HandoffConfig', 'HandoffResult', 'HandoffInputData',
'ContextPolicy', 'HandoffError', 'HandoffCycleError',
@@ -214,6 +214,7 @@ __all__ = [
'Handoff',
'handoff',
'handoff_filters',
'parallel_handoffs',
'RECOMMENDED_PROMPT_PREFIX',
'prompt_with_handoff_instructions',
'HandoffConfig',
@@ -176,28 +176,19 @@ _shared_apps = {} # Dict of port -> FastAPI app
# Don't import FastAPI dependencies here - use lazy loading instead
if TYPE_CHECKING:
from ..approval.protocols import ApprovalConfig, ApprovalProtocol
from ..config.feature_configs import LearnConfig, MemoryConfig
from ..context.models import ContextConfig
from ..context.manager import ContextManager
from ..knowledge.knowledge import Knowledge
from ..agent.autonomy import AutonomyConfig
from ..task.task import Task
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
from ..eval.results import EvaluationLoopResult
class BudgetExceededError(Exception):
"""Raised when an agent exceeds its max_budget.
Usage:
try:
agent.start("...")
except BudgetExceededError as e:
print(f"Agent '{e.agent_name}' spent ${e.total_cost:.4f} of ${e.max_budget:.4f}")
"""
def __init__(self, agent_name: str, total_cost: float, max_budget: float):
self.agent_name = agent_name
self.total_cost = total_cost
self.max_budget = max_budget
super().__init__(
f"Agent '{agent_name}' exceeded budget: "
f"${total_cost:.4f} >= ${max_budget:.4f}"
)
# Import structured error from central errors module
from ..errors import BudgetExceededError
class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin):
# Class-level counter for generating unique display names for nameless agents
@@ -476,24 +467,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
# Each follows: False=disabled, True=defaults, Config=custom
# ============================================================
memory: Optional[Any] = None, # Union[bool, MemoryConfig, MemoryManager]
knowledge: Optional[Union[bool, List[str], Any]] = None, # Union[bool, list, KnowledgeConfig, Knowledge]
planning: Optional[Union[bool, Any]] = False, # Union[bool, PlanningConfig]
reflection: Optional[Union[bool, Any]] = None, # Union[bool, ReflectionConfig]
guardrails: Optional[Union[bool, Callable, Any]] = None, # Union[bool, Callable, GuardrailConfig]
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
guardrails: Optional[Union[bool, str, Callable, 'GuardrailConfig']] = None,
web: Optional[Union[bool, str, 'WebConfig']] = None,
context: Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']] = None,
autonomy: Optional[Union[bool, str, Dict[str, Any], 'AutonomyConfig']] = None,
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
templates: Optional[Any] = None, # TemplateConfig
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
output: Optional[Union[bool, str, Dict[str, Any], 'OutputConfig']] = None,
execution: Optional[Union[bool, str, Dict[str, Any], 'ExecutionConfig']] = None,
templates: Optional[Union[Dict[str, Any], 'TemplateConfig']] = None,
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
):
"""Initialize an Agent instance.
@@ -538,22 +529,32 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
- WebConfig: Custom configuration
context: Context management. Accepts:
- bool: True enables with defaults
- ManagerConfig: Custom configuration
- str: Preset name ("sliding_window", "summarize", "truncate")
- Dict[str, Any]: ContextConfig fields
- ContextConfig: Custom configuration
- ContextManager: Pre-configured instance
autonomy: Autonomy settings. Accepts:
- bool: True enables with defaults
- Dict: Configuration dict
- str: Level preset ("suggest", "auto_edit", "full_auto")
- Dict[str, Any]: Configuration dict
- AutonomyConfig: Custom configuration
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
Still works for backward compatibility.
output: Output configuration. Accepts:
- bool: True=default OutputConfig, False=disabled
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
- Dict[str, Any]: Config overrides (e.g. {"verbose": 2, "stream": True})
- OutputConfig: Custom configuration
Controls: verbose, markdown, stream, metrics, reasoning_steps
execution: Execution configuration. Accepts:
- bool: True=default ExecutionConfig, False=disabled
- str: Preset name ("fast", "balanced", "thorough")
- Dict[str, Any]: Config overrides (e.g. {"max_iter": 10, "max_rpm": 60})
- ExecutionConfig: Custom configuration
Controls: max_iter, max_rpm, max_execution_time, max_retry_limit
templates: Template configuration (TemplateConfig).
templates: Template configuration. Accepts:
- Dict[str, Any]: Template fields (e.g. {"system": "...", "prompt": "..."})
- TemplateConfig: Custom configuration
Controls: system_template, prompt_template, response_template
caching: Caching configuration. Accepts:
- bool: True enables with defaults
@@ -565,7 +566,9 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
- List[str]: Skill directory paths
- SkillsConfig: Custom configuration
learn: Continuous learning configuration. Accepts:
- bool: True enables with defaults, False disables
- bool: True enables with defaults (AGENTIC mode), False disables
- str: Mode string ("disabled", "agentic", "propose")
- Dict[str, Any]: Config fields (e.g. {"mode": "agentic", "backend": "sqlite"})
- LearnConfig: Custom configuration
Learning is a first-class citizen, peer to memory. It captures patterns,
preferences, and insights from interactions to improve future responses.
@@ -790,6 +793,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
output_template = getattr(_output_config, 'template', None) # Response template
tool_output_limit = getattr(_output_config, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT)
else:
# Fallback defaults match silent mode (zero overhead)
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
@@ -798,6 +802,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
status_trace = False
simple_output = False
editor_output = False
tool_output_limit = DEFAULT_TOOL_OUTPUT_LIMIT
# Enable editor output mode if configured (beginner-friendly, takes priority)
# Shows: Step 1: 📄 Creating file: path → ✓ Done
@@ -1068,8 +1073,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
_learn_config = learn
elif isinstance(learn, dict):
_learn_config = LearnConfig(**learn)
elif isinstance(learn, str):
# String mode: "disabled", "agentic", "propose"
from ..memory.learn.protocols import LearnMode
if learn == "disabled":
_learn_config = None
elif learn == "agentic":
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
elif learn == "propose":
_learn_config = LearnConfig(mode=LearnMode.PROPOSE)
else:
# Unknown string mode, disable learning
_learn_config = None
else:
_learn_config = learn # Pass through
logging.warning(
"Unsupported learn= value %r; expected bool, dict, or LearnConfig. "
"Learning disabled.", learn
)
_learn_config = None
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
# Fallback to memory.learn for backward compatibility
if _memory_config.learn:
@@ -1518,6 +1539,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
self._init_memory(memory, user_id)
self.verbose = verbose
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
self.tool_output_limit = tool_output_limit # Configurable tool output limit
self.allow_delegation = allow_delegation
self.step_callback = step_callback
# Token budget guard (zero overhead when _max_budget is None)
@@ -1642,6 +1664,12 @@ Your Goal: {self.goal}
self._approval_backend = approval.backend
self._approve_all_tools = approval.all_tools
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
elif isinstance(approval, dict):
# Dict config: convert to ApprovalConfig
approval_config = ApprovalConfig(**approval)
self._approval_backend = approval_config.backend
self._approve_all_tools = approval_config.all_tools
self._approval_timeout = approval_config.timeout
else:
# Plain backend object — dangerous tools only, backend default timeout
self._approval_backend = approval
@@ -1949,6 +1977,64 @@ Your Goal: {self.goal}
elif hasattr(self._context_param, 'process'):
# Already a ContextManager instance
self._context_manager = self._context_param
elif isinstance(self._context_param, str):
# String preset: "sliding_window", "summarize", "truncate"
from ..config.presets import CONTEXT_PRESETS
preset_config = CONTEXT_PRESETS.get(self._context_param)
if preset_config is not None:
# Convert preset to ContextConfig, then to ManagerConfig
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**preset_config)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
config=manager_config,
agent_name=self.name or "Agent",
session_cache=self._session_dedup_cache,
llm_summarize_fn=None,
)
except Exception as e:
logging.debug(f"Context preset conversion failed: {e}")
self._context_manager = None
else:
# Unknown string preset, disable
self._context_manager = None
elif isinstance(self._context_param, dict):
# Dict config: convert to ContextConfig, then to ManagerConfig
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**self._context_param)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
config=manager_config,
agent_name=self.name or "Agent",
session_cache=self._session_dedup_cache,
llm_summarize_fn=self._create_llm_summarize_fn() if llm_summarize_enabled else None,
)
except Exception as e:
logging.debug(f"Context dict conversion failed: {e}")
self._context_manager = None
else:
# Unknown type, disable
self._context_manager = None
@@ -2165,6 +2251,25 @@ Summary:"""
# Extract verification_hooks from AutonomyConfig if provided
if autonomy.verification_hooks and not verification_hooks:
self._verification_hooks = autonomy.verification_hooks
elif isinstance(autonomy, str):
# String preset: "suggest", "auto_edit", "full_auto"
from ..config.presets import AUTONOMY_PRESETS
preset_config = AUTONOMY_PRESETS.get(autonomy)
if preset_config is not None:
config = AutonomyConfig.from_dict(preset_config)
else:
# Unknown string preset — disable autonomy
self.autonomy_enabled = False
self.autonomy_config = {}
self._autonomy_trigger = None
self._doom_loop_tracker = None
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = False
return
else:
self.autonomy_enabled = False
self.autonomy_config = {}
@@ -13,6 +13,7 @@ import logging
from praisonaiagents._logging import get_logger
import asyncio
import threading
from ..errors import BudgetExceededError
# Fallback helpers to avoid circular imports
def _get_console():
@@ -666,7 +667,13 @@ Your Goal: {self.goal}"""
self._llm_call_count += 1
if self._max_budget and self._total_cost >= self._max_budget:
if self._on_budget_exceeded == "stop":
raise BudgetExceededError(self.name, self._total_cost, self._max_budget)
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${self._total_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=self._total_cost,
agent_id=self.name
)
elif self._on_budget_exceeded == "warn":
logging.warning(
f"[budget] {self.name}: ${self._total_cost:.4f} exceeded "
@@ -692,6 +699,8 @@ Your Goal: {self.goal}"""
return final_response
except BudgetExceededError:
raise
except Exception as e:
error_str = str(e).lower()
@@ -14,6 +14,7 @@ from praisonaiagents._logging import get_logger
import asyncio
import threading
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
@@ -1013,6 +1014,7 @@ Write the complete compiled report:"""
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import threading
import time
import asyncio
@@ -50,6 +50,7 @@ class HandoffConfig:
detect_cycles: Enable cycle detection to prevent infinite loops
max_depth: Maximum handoff chain depth
async_mode: Enable async execution
allow_parallel: Enable parallel execution (from Delegator)
on_handoff: Callback when handoff starts
on_complete: Callback when handoff completes
on_error: Callback when handoff fails
@@ -62,7 +63,7 @@ class HandoffConfig:
# Execution control
timeout_seconds: float = 300.0
max_concurrent: int = 3
max_concurrent: int = 5
# Safety
detect_cycles: bool = True
@@ -70,6 +71,7 @@ class HandoffConfig:
# Execution mode
async_mode: bool = False
allow_parallel: bool = False
# Callbacks
on_handoff: Optional[Callable] = None
@@ -88,6 +90,7 @@ class HandoffConfig:
"detect_cycles": self.detect_cycles,
"max_depth": self.max_depth,
"async_mode": self.async_mode,
"allow_parallel": self.allow_parallel,
}
@classmethod
@@ -97,29 +100,13 @@ class HandoffConfig:
data["context_policy"] = ContextPolicy(data["context_policy"])
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
class HandoffError(Exception):
"""Base exception for handoff errors."""
pass
class HandoffCycleError(HandoffError):
"""Raised when a cycle is detected in handoff chain."""
def __init__(self, chain: List[str]):
self.chain = chain
super().__init__(f"Handoff cycle detected: {' -> '.join(chain)}")
class HandoffDepthError(HandoffError):
"""Raised when max handoff depth is exceeded."""
def __init__(self, depth: int, max_depth: int):
self.depth = depth
self.max_depth = max_depth
super().__init__(f"Max handoff depth exceeded: {depth} > {max_depth}")
class HandoffTimeoutError(HandoffError):
"""Raised when handoff times out."""
def __init__(self, timeout: float, agent_name: str):
self.timeout = timeout
self.agent_name = agent_name
super().__init__(f"Handoff to {agent_name} timed out after {timeout}s")
# Import structured error hierarchy from central errors module
from ..errors import (
HandoffError,
HandoffCycleError,
HandoffDepthError,
HandoffTimeoutError
)
# Thread-local storage for tracking handoff chains
_handoff_context = threading.local()
@@ -267,12 +254,26 @@ class Handoff:
if self.config.detect_cycles:
chain = _get_handoff_chain()
if target_name in chain:
raise HandoffCycleError(chain + [target_name])
cycle_path = chain + [target_name]
raise HandoffCycleError(
f"Handoff cycle detected: {' -> '.join(cycle_path)}",
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
target_agent=target_name,
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
cycle_path=cycle_path
)
# Check depth
current_depth = _get_handoff_depth()
if current_depth >= self.config.max_depth:
raise HandoffDepthError(current_depth + 1, self.config.max_depth)
raise HandoffDepthError(
f"Max handoff depth exceeded: {current_depth + 1} > {self.config.max_depth}",
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
target_agent=target_name,
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
max_depth=self.config.max_depth,
current_depth=current_depth + 1
)
def _prepare_context(self, source_agent: 'Agent', kwargs: Dict[str, Any]) -> HandoffInputData:
"""
@@ -538,7 +539,13 @@ class Handoff:
handoff_depth=_get_handoff_depth(),
)
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
raise HandoffTimeoutError(self.config.timeout_seconds, self.agent.name)
raise HandoffTimeoutError(
f"Handoff to {self.agent.name} timed out after {self.config.timeout_seconds}s",
timeout_seconds=self.config.timeout_seconds,
source_agent=source_agent.name if hasattr(source_agent, "name") else "unknown",
target_agent=self.agent.name,
agent_id=source_agent.name if hasattr(source_agent, "name") else "unknown"
)
except Exception as e:
result = HandoffResult(
success=False,
@@ -818,6 +825,102 @@ When you determine that a task would be better handled by another agent with spe
use the transfer tool to hand off the task. The receiving agent will have the full context of
the conversation and will continue helping the user."""
async def parallel_handoffs(
source: 'Agent',
targets: List[tuple], # [(agent, prompt), ...]
max_concurrent: int = 5,
config: Optional[HandoffConfig] = None,
) -> List['HandoffResult']:
"""
Execute multiple handoffs in parallel with concurrency control.
This function provides the parallel execution capabilities from SubagentDelegator
while using the unified Handoff system.
Args:
source: Source agent performing the handoffs
targets: List of (agent, prompt) tuples for parallel execution
max_concurrent: Maximum concurrent handoffs (overrides config)
config: Optional HandoffConfig for additional settings
Returns:
List of HandoffResult objects from each parallel handoff
Example:
results = await parallel_handoffs(
source=main_agent,
targets=[
(research_agent, "Research topic X"),
(analysis_agent, "Analyze data Y"),
(summary_agent, "Summarize findings Z")
],
max_concurrent=3
)
"""
if not hasattr(source, 'handoff_to_async'):
raise ValueError("Source agent must support async handoffs")
# Check if parallel execution is allowed by config
if config and not config.allow_parallel:
raise ValueError("Parallel handoffs are disabled in the provided HandoffConfig")
# Use config's max_concurrent if max_concurrent wasn't explicitly set and config is provided
if config and max_concurrent == 5: # Check if default value
effective_max_concurrent = config.max_concurrent
else:
effective_max_concurrent = max_concurrent
# Create semaphore for concurrency control (0 or negative = unlimited)
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
async def _run_one(agent, prompt):
async def _do_handoff():
try:
return await source.handoff_to_async(agent, prompt, config=config)
except Exception as e:
logger.error(f"Parallel handoff failed to {getattr(agent, 'name', str(agent))}: {e}")
# Return a failed HandoffResult object
return HandoffResult(
success=False,
response="",
target_agent=getattr(agent, 'name', str(agent)),
source_agent=getattr(source, 'name', str(source)),
duration_seconds=0.0,
error=str(e),
handoff_depth=0
)
if semaphore:
async with semaphore:
return await _do_handoff()
else:
return await _do_handoff()
# Execute all handoffs concurrently
tasks = [_run_one(agent, prompt) for agent, prompt in targets]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Convert any exceptions to failed results
processed_results = []
for i, result in enumerate(results):
if isinstance(result, HandoffResult):
processed_results.append(result)
else:
# Handle Exception, BaseException (like CancelledError), or any other unexpected result
agent, _prompt = targets[i] # Prefix with _ to mark intentionally unused
processed_results.append(HandoffResult(
success=False,
response="",
target_agent=getattr(agent, 'name', str(agent)),
source_agent=getattr(source, 'name', str(source)),
duration_seconds=0.0,
error=str(result),
handoff_depth=0
))
return processed_results
def prompt_with_handoff_instructions(base_prompt: str, agent: 'Agent') -> str:
"""
Add handoff instructions to an agent's prompt.
@@ -216,10 +216,11 @@ class ToolExecutionMixin:
else:
# Apply default limit even without context management
# This prevents runaway tool outputs from causing overflow
if len(result_str) > DEFAULT_TOOL_OUTPUT_LIMIT:
limit = getattr(self, 'tool_output_limit', 16000)
if len(result_str) > limit:
# Use smart truncation format that judge recognizes as OK
tail_size = min(DEFAULT_TOOL_OUTPUT_LIMIT // 5, 2000)
head = result_str[:DEFAULT_TOOL_OUTPUT_LIMIT - tail_size]
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
else:
@@ -229,7 +230,7 @@ class ToolExecutionMixin:
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
# For dicts, truncate large string fields (e.g., raw_content from search)
if isinstance(result, dict):
max_field_chars = DEFAULT_TOOL_OUTPUT_LIMIT if not self.context_manager else None
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars)
else:
result = truncated
@@ -108,6 +108,93 @@ def get_multimodal_message(text_prompt: str, images: list) -> list:
})
return content
def _prepare_task_prompt(task, task_description, context_text=None):
"""
Prepare task prompt with context and memory (DRY helper).
"""
# Build task prompt - only use "User Input/Topic" format if there's actual content
if context_text and context_text.strip():
task_prompt = f"""
User Input/Topic: {context_text}
Task: {task_description}
Expected Output: {task.expected_output}
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
else:
task_prompt = f"""
You need to do the following task: {task_description}.
Expected Output: {task.expected_output}."""
# Add memory context if available
if task.memory:
try:
memory_context = task.memory.build_context_for_task(task.description)
if memory_context:
# Log detailed memory context for debugging
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
# Include actual memory content without verbose headers (essential for AI agent functionality)
task_prompt += f"\n\n{memory_context}"
except Exception as e:
logger.error(f"Error getting memory context: {e}")
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
return task_prompt
def _execute_with_agent_sync(executor_agent, task_prompt, task, tools, stream=None):
"""
Execute task with agent synchronously (DRY helper).
"""
if task.images:
return executor_agent.chat(
get_multimodal_message(task_prompt, task.images),
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
stream=stream,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
else:
return executor_agent.chat(
task_prompt,
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
stream=stream,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, stream=None):
"""
Execute task with agent asynchronously (DRY helper).
"""
if task.images:
return await executor_agent.achat(
get_multimodal_message(task_prompt, task.images),
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
stream=stream,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
else:
return await executor_agent.achat(
task_prompt,
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
stream=stream,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
def process_task_context(context_item, verbose=0, user_id=None):
"""
Process a single context item for task execution.
@@ -703,59 +790,17 @@ class AgentTeam:
context_separator = '\n\n'
context_text = context_separator.join(unique_contexts)
# Build task prompt - only use "User Input/Topic" format if there's actual content
if context_text and context_text.strip():
task_prompt = f"""
User Input/Topic: {context_text}
Task: {task_description}
Expected Output: {task.expected_output}
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
else:
task_prompt = f"""
You need to do the following task: {task_description}.
Expected Output: {task.expected_output}."""
# Add memory context if available
if task.memory:
try:
memory_context = task.memory.build_context_for_task(task.description)
if memory_context:
# Log detailed memory context for debugging
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
# Include actual memory content without verbose headers (essential for AI agent functionality)
task_prompt += f"\n\n{memory_context}"
except Exception as e:
logger.error(f"Error getting memory context: {e}")
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
# Build task prompt using DRY helper
task_prompt = _prepare_task_prompt(task, task_description, context_text)
if self.verbose >= 2:
logger.info(f"Executing task {task_id}: {task_description} using {executor_agent.display_name}")
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
if task.images:
# Use shared multimodal helper (DRY - defined at module level)
agent_output = await executor_agent.achat(
get_multimodal_message(task_prompt, task.images),
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
else:
agent_output = await executor_agent.achat(
task_prompt,
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
task_name=task.name,
task_description=task.description,
task_id=task.id
)
# Execute with agent using DRY helper (fixes missing stream parameter)
agent_output = await _execute_with_agent_async(
executor_agent, task_prompt, task, tools, stream=self.stream
)
if agent_output:
# Store the response in memory
@@ -1066,60 +1111,17 @@ Expected Output: {task.expected_output}."""
context_separator = '\n\n'
context_text = context_separator.join(unique_contexts)
# Build task prompt - only use "User Input/Topic" format if there's actual content
if context_text and context_text.strip():
task_prompt = f"""
User Input/Topic: {context_text}
Task: {task_description}
Expected Output: {task.expected_output}
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
else:
task_prompt = f"""
You need to do the following task: {task_description}.
Expected Output: {task.expected_output}."""
# Add memory context if available
if task.memory:
try:
memory_context = task.memory.build_context_for_task(task.description)
if memory_context:
# Log detailed memory context for debugging
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
# Include actual memory content without verbose headers (essential for AI agent functionality)
task_prompt += f"\n\n{memory_context}"
except Exception as e:
logger.error(f"Error getting memory context: {e}")
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
# Build task prompt using DRY helper
task_prompt = _prepare_task_prompt(task, task_description, context_text)
if self.verbose >= 2:
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
if task.images:
# Use shared multimodal helper (DRY - defined at module level)
agent_output = executor_agent.chat(
get_multimodal_message(task_prompt, task.images),
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
task_name=task.name,
task_description=task.description,
task_id=task_id
)
else:
agent_output = executor_agent.chat(
task_prompt,
tools=tools,
output_json=task.output_json,
output_pydantic=task.output_pydantic,
stream=self.stream,
task_name=task.name,
task_description=task.description,
task_id=task_id
)
# Execute with agent using DRY helper
agent_output = _execute_with_agent_sync(
executor_agent, task_prompt, task, tools, stream=self.stream
)
if agent_output:
# Store the response in memory
@@ -8,6 +8,7 @@ and context. Inspired by Gemini CLI's delegate-to-agent pattern.
import logging
from praisonaiagents._logging import get_logger
import asyncio
import warnings
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Callable
from enum import Enum
@@ -127,6 +128,12 @@ class SubagentDelegator:
agent_factory: Factory function to create agents
on_task_complete: Callback when task completes
"""
warnings.warn(
"SubagentDelegator is deprecated. Use Agent.handoff_to() or "
"parallel_handoffs() instead.",
DeprecationWarning,
stacklevel=2,
)
self.config = config or DelegationConfig()
self.agent_factory = agent_factory
self.on_task_complete = on_task_complete
@@ -50,20 +50,30 @@ logger = get_logger(__name__)
# ── Singleton registry ───────────────────────────────────────────────────────
import threading
_registry: Optional[ApprovalRegistry] = None
_registry_lock = threading.Lock()
def get_approval_registry() -> ApprovalRegistry:
"""Return the global singleton :class:`ApprovalRegistry`."""
global _registry
if _registry is None:
_registry = ApprovalRegistry()
return _registry
with _registry_lock:
if _registry is None:
_registry = ApprovalRegistry()
return _registry
# ── Backward-compatible API (delegates to registry) ─────────────────────────
# These globals are kept for code that imports them directly.
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
# NOTE: Accessing these will trigger lazy initialization of the registry
def __getattr__(name):
"""Module-level attribute access for lazy initialization."""
if name == "APPROVAL_REQUIRED_TOOLS":
return get_approval_registry()._required_tools
elif name == "TOOL_RISK_LEVELS":
return get_approval_registry()._risk_levels
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
# Legacy global callback holder — set_approval_callback wraps it into a backend
approval_callback: Optional[Callable] = None
@@ -114,14 +124,16 @@ def configure_default_approvals() -> None:
reg.add_requirement(tool_name, risk_level)
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
get_approval_registry().add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
reg = get_approval_registry()
reg.add_requirement(tool_name, risk_level)
reg._required_tools.add(tool_name)
reg._risk_levels[tool_name] = risk_level
def remove_approval_requirement(tool_name: str) -> None:
get_approval_registry().remove_requirement(tool_name)
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
TOOL_RISK_LEVELS.pop(tool_name, None)
reg = get_approval_registry()
reg.remove_requirement(tool_name)
reg._required_tools.discard(tool_name)
reg._risk_levels.pop(tool_name, None)
def is_approval_required(tool_name: str) -> bool:
return get_approval_registry().is_required(tool_name)
@@ -157,8 +169,8 @@ def require_approval(risk_level: RiskLevel = "high"):
tool_name = getattr(func, '__name__', str(func))
reg = get_approval_registry()
reg.add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
reg._required_tools.add(tool_name)
reg._risk_levels[tool_name] = risk_level
@wraps(func)
def wrapper(*args, **kwargs):
@@ -217,7 +217,11 @@ class CheckpointService:
cwd=self.config.workspace_dir
)
stdout, stderr = await process.communicate()
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
except asyncio.TimeoutError:
process.kill()
raise RuntimeError(f"Git command timed out after 30 seconds: {' '.join(args)}")
if process.returncode != 0:
error_msg = stderr.decode().strip()
@@ -626,6 +626,16 @@ class OutputConfig:
# Example: "# {{title}}\n\n{{content}}\n\n---\nGenerated by AI"
template: Optional[str] = None
# Tool output limit - maximum characters for tool output
# Default: 16000 chars (≈ 4000 tokens) to prevent runaway tool outputs
tool_output_limit: int = 16000
def __post_init__(self) -> None:
if not isinstance(self.tool_output_limit, int) or self.tool_output_limit <= 0:
raise ValueError(
"OutputConfig.tool_output_limit must be a positive integer."
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -642,6 +652,7 @@ class OutputConfig:
"editor_output": self.editor_output,
"output_file": self.output_file,
"template": self.template,
"tool_output_limit": self.tool_output_limit,
}
@@ -300,9 +300,9 @@ CONTEXT_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
AUTONOMY_PRESETS: Dict[str, Dict[str, Any]] = {
"suggest": {"mode": "suggest"},
"auto_edit": {"mode": "auto_edit"},
"full_auto": {"mode": "full_auto"},
"suggest": {"level": "suggest"},
"auto_edit": {"level": "auto_edit"},
"full_auto": {"level": "full_auto"},
}
@@ -291,7 +291,20 @@ class MultiAgentLedger:
self._shared_tokens: int = 0 # Tokens shared across agents
def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
"""Get or create ledger for an agent."""
"""Get or create ledger for an agent.
Args:
agent_id: Unique identifier for the agent.
Returns:
ContextLedgerManager for this agent.
Raises:
ValueError: If agent_id is empty or contains only whitespace.
"""
if not agent_id or not agent_id.strip():
raise ValueError("agent_id must be a non-empty string")
if agent_id not in self._agents:
self._agents[agent_id] = ContextLedgerManager(agent_id)
return self._agents[agent_id]
@@ -514,7 +514,10 @@ class ContextMonitor:
return str(output_path)
def _get_output_path(self, agent_name: str = "") -> Path:
"""Get output path, optionally per-agent."""
"""Get output path, optionally per-agent.
Security: Agent names are sanitized to prevent path traversal.
"""
if not self.multi_agent_files or not agent_name:
return self.path
@@ -522,10 +525,32 @@ class ContextMonitor:
stem = self.path.stem
suffix = self.path.suffix or (".json" if self.format == "json" else ".txt")
# Sanitize agent name
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())
# Sanitize agent name:
# 1. Strip any path separators first (defence in depth)
safe_name = agent_name.replace('/', '_').replace('\\', '_')
# 2. Collapse to safe chars only
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
# 3. Enforce length limit to prevent filesystem issues
safe_name = safe_name[:64] if safe_name else 'unknown'
# 4. Strip leading/trailing underscores / dots
safe_name = safe_name.strip('_.')
if not safe_name:
safe_name = 'unknown'
return self.path.parent / f"{stem}_{safe_name}{suffix}"
result = self.path.parent / f"{stem}_{safe_name}{suffix}"
# Verify the resolved path stays within the expected parent dir
resolved = result.resolve()
parent_resolved = self.path.parent.resolve()
try:
resolved.relative_to(parent_resolved)
except ValueError:
# Fallback to a safe hash-based name
import hashlib
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
result = self.path.parent / f"{stem}_{hash_name}{suffix}"
return result
def _get_warnings(
self,
@@ -0,0 +1,345 @@
"""
Structured exception hierarchy for PraisonAI SDK.
Provides uniform error semantics for consistent handling across:
- Multi-agent orchestration
- Tool execution
- LLM interactions
- Memory operations
- External integrations
"""
from typing import Literal, Protocol, runtime_checkable, Optional, Dict, Any
import uuid
@runtime_checkable
class ErrorContextProtocol(Protocol):
"""Protocol for structured error context propagation."""
agent_id: str
run_id: str
is_retryable: bool
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"]
class PraisonAIError(Exception):
"""
Base error class with structured context for PraisonAI SDK.
All PraisonAI errors inherit from this to ensure uniform error handling,
context propagation, and observability hooks.
"""
def __init__(
self,
message: str,
agent_id: str = "unknown",
run_id: Optional[str] = None,
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"] = "validation",
is_retryable: bool = False,
context: Optional[Dict[str, Any]] = None
):
super().__init__(message)
self.message = message
self.agent_id = agent_id
self.run_id = run_id or str(uuid.uuid4())
self.error_category = error_category
self.is_retryable = is_retryable
self.context = context or {}
def __str__(self) -> str:
return f"[{self.error_category}] {self.message} (agent: {self.agent_id}, run: {self.run_id})"
class ToolExecutionError(PraisonAIError):
"""
Tool execution failed.
Includes tool name and execution context for better debugging
and selective retry policies.
"""
def __init__(
self,
message: str,
tool_name: str = "unknown",
agent_id: str = "unknown",
run_id: Optional[str] = None,
is_retryable: bool = True, # Most tool errors are retryable
context: Optional[Dict[str, Any]] = None
):
context = context or {}
context["tool_name"] = tool_name
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="tool",
is_retryable=is_retryable,
context=context
)
self.tool_name = tool_name
class LLMError(PraisonAIError):
"""
LLM interaction failed.
Distinguishes between rate limits (retryable) vs model errors (fatal).
"""
def __init__(
self,
message: str,
model_name: str = "unknown",
agent_id: str = "unknown",
run_id: Optional[str] = None,
is_retryable: bool = False, # Default to non-retryable unless specified
context: Optional[Dict[str, Any]] = None
):
context = context or {}
context["model_name"] = model_name
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="llm",
is_retryable=is_retryable,
context=context
)
self.model_name = model_name
class BudgetExceededError(PraisonAIError):
"""
Budget limits exceeded (tokens, time, etc).
Generally not retryable without intervention.
"""
def __init__(
self,
message_or_agent_name,
total_cost_or_budget_type = None,
max_budget_or_limit = None,
budget_type: str = "tokens",
limit: Optional[float] = None,
used: Optional[float] = None,
agent_id: str = "unknown",
run_id: Optional[str] = None,
context: Optional[Dict[str, Any]] = None
):
# Handle backward compatibility: old constructor BudgetExceededError(agent_name, total_cost, max_budget)
if (isinstance(message_or_agent_name, str) and
isinstance(total_cost_or_budget_type, (int, float)) and
isinstance(max_budget_or_limit, (int, float)) and
budget_type == "tokens"): # Default value indicates old constructor
# Old constructor format
agent_name = message_or_agent_name
total_cost = float(total_cost_or_budget_type)
max_budget = float(max_budget_or_limit)
message = f"Agent '{agent_name}' exceeded budget: ${total_cost:.4f} >= ${max_budget:.4f}"
context = context or {}
context.update({
"budget_type": "cost", # Legacy errors are cost-based
"limit": max_budget,
"used": total_cost
})
super().__init__(
message,
agent_id=agent_name,
run_id=run_id,
error_category="budget",
is_retryable=False,
context=context
)
self.budget_type = "cost"
self.limit = max_budget
self.used = total_cost
self.agent_name = agent_name
self.total_cost = total_cost
self.max_budget = max_budget
else:
# New constructor format
message = str(message_or_agent_name)
if total_cost_or_budget_type is not None and isinstance(total_cost_or_budget_type, str):
budget_type = total_cost_or_budget_type
if max_budget_or_limit is not None:
if limit is None:
limit = max_budget_or_limit
context = context or {}
context.update({
"budget_type": budget_type,
"limit": limit,
"used": used
})
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="budget",
is_retryable=False,
context=context
)
self.budget_type = budget_type
self.limit = limit
self.used = used
# Legacy attributes for backward compatibility
self.agent_name = agent_id
self.total_cost = used
self.max_budget = limit
class ValidationError(PraisonAIError):
"""
Input validation failed.
Usually indicates programming errors, not retryable.
"""
def __init__(
self,
message: str,
field_name: Optional[str] = None,
agent_id: str = "unknown",
run_id: Optional[str] = None,
context: Optional[Dict[str, Any]] = None
):
context = context or {}
if field_name:
context["field_name"] = field_name
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="validation",
is_retryable=False, # Validation errors need code fixes
context=context
)
self.field_name = field_name
class NetworkError(PraisonAIError):
"""
Network/external service error.
Often retryable with backoff.
"""
def __init__(
self,
message: str,
service_name: str = "unknown",
status_code: Optional[int] = None,
agent_id: str = "unknown",
run_id: Optional[str] = None,
is_retryable: bool = True, # Most network errors are retryable
context: Optional[Dict[str, Any]] = None
):
context = context or {}
context.update({
"service_name": service_name,
"status_code": status_code
})
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="network",
is_retryable=is_retryable,
context=context
)
self.service_name = service_name
self.status_code = status_code
class HandoffError(PraisonAIError):
"""
Agent handoff/delegation failed.
Includes source/target agent context for multi-agent debugging.
"""
def __init__(
self,
message: str,
source_agent: str = "unknown",
target_agent: Optional[str] = None,
agent_id: str = "unknown",
run_id: Optional[str] = None,
is_retryable: bool = False, # Handoff errors usually need investigation
context: Optional[Dict[str, Any]] = None
):
context = context or {}
context.update({
"source_agent": source_agent,
"target_agent": target_agent
})
super().__init__(
message,
agent_id=agent_id,
run_id=run_id,
error_category="handoff",
is_retryable=is_retryable,
context=context
)
self.source_agent = source_agent
self.target_agent = target_agent
# Specialized handoff errors (maintain backward compatibility)
class HandoffCycleError(HandoffError):
"""Circular handoff dependency detected."""
def __init__(self, message: str, cycle_path: Optional[list] = None, **kwargs):
super().__init__(message, **kwargs)
if cycle_path:
self.context["cycle_path"] = cycle_path
# Backward compatibility alias
self.chain = cycle_path
class HandoffDepthError(HandoffError):
"""Maximum handoff depth exceeded."""
def __init__(self, message: str, max_depth: Optional[int] = None, current_depth: Optional[int] = None, **kwargs):
super().__init__(message, **kwargs)
self.context.update({
"max_depth": max_depth,
"current_depth": current_depth
})
# Backward compatibility aliases
self.max_depth = max_depth
self.depth = current_depth
class HandoffTimeoutError(HandoffError):
"""Handoff operation timed out."""
def __init__(self, message: str, timeout_seconds: Optional[float] = None, **kwargs):
super().__init__(message, is_retryable=True, **kwargs) # Timeouts may be retryable
if timeout_seconds:
self.context["timeout_seconds"] = timeout_seconds
# Backward compatibility alias
self.timeout = timeout_seconds
# Export all error types for easy importing
__all__ = [
"ErrorContextProtocol",
"PraisonAIError",
"ToolExecutionError",
"LLMError",
"BudgetExceededError",
"ValidationError",
"NetworkError",
"HandoffError",
"HandoffCycleError",
"HandoffDepthError",
"HandoffTimeoutError"
]
@@ -291,21 +291,6 @@ class ObservabilityHooks:
"final_stage": self._current_stage.name if self._current_stage else None,
}
# Global hooks instance (opt-in)
_global_hooks: Optional[ObservabilityHooks] = None
def get_hooks() -> Optional[ObservabilityHooks]:
"""Get global observability hooks."""
return _global_hooks
def enable_observability() -> ObservabilityHooks:
"""Enable global observability."""
global _global_hooks
if _global_hooks is None:
_global_hooks = ObservabilityHooks(enabled=True)
return _global_hooks
def disable_observability():
"""Disable global observability."""
global _global_hooks
_global_hooks = None
# NOTE: Observability is now per-agent/per-pipeline to comply with multi-agent safety.
# Each agent creates its own ObservabilityHooks instance when observe=True.
# See agent.py:2236 for the correct per-instance pattern.
@@ -5,6 +5,7 @@ Manages registration and lookup of hooks for different events.
"""
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Callable, Union
from functools import wraps
@@ -319,18 +320,21 @@ class HookRegistry:
# Global default registry
_default_registry: Optional[HookRegistry] = None
_default_registry_lock = threading.Lock()
def get_default_registry() -> HookRegistry:
"""Get the default global hook registry."""
global _default_registry
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
with _default_registry_lock:
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
def set_default_registry(registry: HookRegistry):
"""Set the default global hook registry."""
global _default_registry
_default_registry = registry
with _default_registry_lock:
_default_registry = registry
# =============================================================================
# Simplified API (beginner-friendly aliases)
@@ -0,0 +1,107 @@
"""
Knowledge Adapter Registry
Provides a registry system for knowledge adapters to replace hardcoded imports
in knowledge/knowledge.py. This enables protocol-driven knowledge backends without
core modifications.
"""
from typing import Callable, List, Optional, Tuple, Type
from ...utils.adapter_registry import AdapterRegistry
from ..protocols import KnowledgeStoreProtocol
class KnowledgeAdapterRegistry(AdapterRegistry[KnowledgeStoreProtocol]):
"""
Thread-safe registry for knowledge adapters.
Replaces hardcoded imports and configurations in knowledge.py
with dynamic adapter discovery and instantiation.
Example:
```python
# Register adapter (typically done on import)
register_knowledge_adapter("sqlite", SQLiteKnowledgeAdapter)
register_knowledge_adapter("chroma", ChromaAdapter) # in wrapper
# Use in Knowledge class
adapter = get_knowledge_adapter("chroma", config=config)
```
"""
def __init__(self):
super().__init__(adapter_type_name="Knowledge")
# Global registry instance
_knowledge_registry = KnowledgeAdapterRegistry()
def register_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
"""Register a knowledge adapter class."""
_knowledge_registry.register_adapter(name, adapter_class)
def register_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
"""Register a knowledge adapter factory function."""
_knowledge_registry.register_factory(name, factory_func)
def get_knowledge_adapter(name: str, **kwargs) -> Optional[KnowledgeStoreProtocol]:
"""Get knowledge adapter instance by name."""
return _knowledge_registry.get_adapter(name, **kwargs)
def list_knowledge_adapters() -> List[str]:
"""List all registered knowledge adapter names."""
return _knowledge_registry.list_adapters()
def get_first_available_knowledge_adapter(
preferences: Optional[List[str]] = None, **kwargs
) -> Optional[Tuple[str, KnowledgeStoreProtocol]]:
"""
Get first available knowledge adapter.
Args:
preferences: List of adapter names to try (defaults to ["mem0", "mongodb"])
**kwargs: Configuration passed to adapter constructor
Returns:
(adapter_name, adapter_instance) tuple or None
"""
if preferences is None:
# Default to actual available adapters per knowledge/adapters/__init__.py
preferences = ["mem0", "mongodb"]
return _knowledge_registry.get_first_available(preferences, **kwargs)
# Canonical aliases per AGENTS.md naming conventions
def add_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
"""Canonical alias for register_knowledge_adapter (preferred naming per AGENTS.md)."""
return register_knowledge_adapter(name, adapter_class)
def add_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
"""Canonical alias for register_knowledge_factory (preferred naming per AGENTS.md)."""
return register_knowledge_factory(name, factory_func)
def has_knowledge_adapter(name: str) -> bool:
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
return _knowledge_registry.is_available(name)
__all__ = [
'KnowledgeAdapterRegistry',
'register_knowledge_adapter',
'register_knowledge_factory',
'get_knowledge_adapter',
'list_knowledge_adapters',
'get_first_available_knowledge_adapter',
# Canonical aliases (preferred)
'add_knowledge_adapter',
'add_knowledge_factory',
'has_knowledge_adapter',
]
@@ -98,6 +98,38 @@ def __getattr__(name):
from .llm import TokenUsage
_lazy_cache[name] = TokenUsage
return TokenUsage
elif name == "LLMProviderProtocol":
from .protocols import LLMProviderProtocol
_lazy_cache[name] = LLMProviderProtocol
return LLMProviderProtocol
elif name == "ModelCapabilitiesProtocol":
from .protocols import ModelCapabilitiesProtocol
_lazy_cache[name] = ModelCapabilitiesProtocol
return ModelCapabilitiesProtocol
elif name == "LLMRateLimiterProtocol":
from .protocols import LLMRateLimiterProtocol
_lazy_cache[name] = LLMRateLimiterProtocol
return LLMRateLimiterProtocol
elif name == "LLMFailoverProtocol":
from .protocols import LLMFailoverProtocol
_lazy_cache[name] = LLMFailoverProtocol
return LLMFailoverProtocol
elif name == "LLMProviderError":
from .protocols import LLMProviderError
_lazy_cache[name] = LLMProviderError
return LLMProviderError
elif name == "RateLimitError":
from .protocols import RateLimitError
_lazy_cache[name] = RateLimitError
return RateLimitError
elif name == "ModelNotAvailableError":
from .protocols import ModelNotAvailableError
_lazy_cache[name] = ModelNotAvailableError
return ModelNotAvailableError
elif name == "ContextLengthExceededError":
from .protocols import ContextLengthExceededError
_lazy_cache[name] = ContextLengthExceededError
return ContextLengthExceededError
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -122,5 +154,15 @@ __all__ = [
"TaskComplexity",
"create_routing_agent",
"RateLimiter",
"TokenUsage"
"TokenUsage",
# Protocols
"LLMProviderProtocol",
"ModelCapabilitiesProtocol",
"LLMRateLimiterProtocol",
"LLMFailoverProtocol",
# Exceptions
"LLMProviderError",
"RateLimitError",
"ModelNotAvailableError",
"ContextLengthExceededError"
]
@@ -0,0 +1,148 @@
"""
LLM Provider Adapters
Concrete implementations of LLMProviderAdapter protocol that replace
scattered provider dispatch logic throughout the core.
This demonstrates the protocol-driven approach for Gap 2.
"""
from ..protocols import LLMProviderProtocol
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
from typing import Dict, Any, List
class DefaultAdapter:
"""Default provider adapter with sensible fallbacks."""
def supports_prompt_caching(self) -> bool:
return False
def should_summarize_tools(self, iter_count: int) -> bool:
return iter_count >= 5 # Conservative default
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return tools # No special formatting by default
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
pass # No post-processing by default
def supports_structured_output(self) -> bool:
return False # Conservative default
def supports_streaming(self) -> bool:
return True # Most providers support streaming
class OllamaAdapter(DefaultAdapter):
"""
Ollama-specific provider adapter.
Demonstrates how to extract Ollama-specific logic from llm.py
scattered provider dispatch into a clean adapter.
"""
def should_summarize_tools(self, iter_count: int) -> bool:
# Replaces: OLLAMA_SUMMARY_ITERATION_THRESHOLD logic
# Must match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD = 1
return iter_count >= 1
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
# Replaces: Ollama-specific post-tool summary branches
if (not state.get('response_text', '').strip() and
state.get('formatted_tools') and
state.get('iteration_count') == 0):
# Add Ollama-specific summary logic here
state['needs_summary'] = True
class AnthropicAdapter(DefaultAdapter):
"""Anthropic/Claude provider adapter."""
def supports_prompt_caching(self) -> bool:
return True # Claude supports prompt caching
def supports_structured_output(self) -> bool:
return True
class GeminiAdapter(DefaultAdapter):
"""Google Gemini provider adapter."""
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Replaces: gemini_internal_tools handling in llm.py
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
formatted = []
for tool in tools:
if tool.get('name') in GEMINI_INTERNAL_TOOLS:
# Convert to Gemini internal tool format
formatted.append({
'type': 'function',
'function': tool
})
else:
formatted.append(tool)
return formatted
def supports_structured_output(self) -> bool:
return True
# Provider adapter registry - public for extension
_provider_adapters: Dict[str, LLMProviderProtocol] = {}
# Register core adapters at import time
_default_adapter = DefaultAdapter()
_provider_adapters['default'] = _default_adapter
_provider_adapters['ollama'] = OllamaAdapter()
_provider_adapters['anthropic'] = AnthropicAdapter()
_provider_adapters['claude'] = AnthropicAdapter() # Alias
_provider_adapters['gemini'] = GeminiAdapter()
def add_provider_adapter(name: str, adapter: LLMProviderProtocol) -> None:
"""
Register a provider adapter by name.
This enables new providers to be added without modifying core code.
Args:
name: Provider name (e.g., "cohere", "huggingface")
adapter: Provider adapter implementing LLMProviderProtocol
"""
_provider_adapters[name] = adapter
def get_provider_adapter(name: str) -> LLMProviderProtocol:
"""
Get provider adapter by name with fallback to default.
Args:
name: Provider name (e.g., "anthropic", "ollama", "gemini")
Returns:
Provider adapter instance (default if name not found)
"""
return _provider_adapters.get(name, _provider_adapters['default'])
def list_provider_adapters() -> List[str]:
"""List all registered provider adapter names."""
return sorted(_provider_adapters.keys())
def has_provider_adapter(name: str) -> bool:
"""Check if a provider adapter is registered."""
return name in _provider_adapters
__all__ = [
'DefaultAdapter',
'OllamaAdapter',
'AnthropicAdapter',
'GeminiAdapter',
'get_provider_adapter',
'add_provider_adapter',
'list_provider_adapters',
'has_provider_adapter',
]
@@ -11,6 +11,7 @@ from praisonaiagents._logging import get_logger
import time
import json
import asyncio
import threading
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Callable, Tuple, TYPE_CHECKING
from pydantic import BaseModel
from dataclasses import dataclass
@@ -2197,6 +2198,7 @@ class OpenAIClient:
# Global client instance (similar to main.py pattern)
_global_client = None
_global_client_params = None
_global_client_lock = threading.Lock()
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> OpenAIClient:
"""
@@ -2216,9 +2218,10 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
normalized_base_url = base_url
current_params = (normalized_api_key, normalized_base_url)
# Only create new client if parameters changed or first time
if _global_client is None or _global_client_params != current_params:
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
_global_client_params = current_params
return _global_client
# Thread-safe client creation
with _global_client_lock:
# Only create new client if parameters changed or first time
if _global_client is None or _global_client_params != current_params:
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
_global_client_params = current_params
return _global_client
@@ -0,0 +1,349 @@
"""
LLM Provider Protocols for PraisonAI Agents.
Defines minimal protocol interfaces for LLM providers to enable
extensibility without vendor lock-in to litellm or any specific provider.
No heavy imports - only stdlib and typing.
"""
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Protocol, Union, runtime_checkable
@runtime_checkable
class LLMProviderProtocol(Protocol):
"""
Protocol defining the interface that LLM providers must implement.
This enables switching between different LLM backends (litellm, openai,
anthropic, local models, etc.) without modifying core agent code.
Implementations must provide both sync and async variants for
production flexibility.
"""
model: str
def chat(
self,
messages: List[Dict[str, Any]],
*,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.0,
max_tokens: Optional[int] = None,
stream: bool = False,
**kwargs: Any,
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
"""
Generate chat completion.
Args:
messages: List of message dicts with 'role' and 'content'
tools: Optional list of tool schemas for function calling
temperature: Sampling temperature (0.0-2.0)
max_tokens: Maximum tokens to generate
stream: Whether to return streaming iterator
**kwargs: Provider-specific options
Returns:
Single response dict or streaming iterator of response chunks
"""
...
async def achat(
self,
messages: List[Dict[str, Any]],
*,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.0,
max_tokens: Optional[int] = None,
stream: bool = False,
**kwargs: Any,
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
"""
Async version of chat.
Args:
messages: List of message dicts with 'role' and 'content'
tools: Optional list of tool schemas for function calling
temperature: Sampling temperature (0.0-2.0)
max_tokens: Maximum tokens to generate
stream: Whether to return async streaming iterator
**kwargs: Provider-specific options
Returns:
Single response dict or async streaming iterator of response chunks
"""
...
def get_token_count(self, text: str) -> int:
"""
Count tokens in text for the current model.
Args:
text: Text to count tokens for
Returns:
Number of tokens
"""
...
def get_context_length(self) -> int:
"""
Get maximum context length for the current model.
Returns:
Maximum context length in tokens
"""
...
@runtime_checkable
class ModelCapabilitiesProtocol(Protocol):
"""
Protocol for querying model capabilities.
Enables runtime capability detection for different models
without hardcoded capability tables.
"""
def supports_streaming(self, model: str) -> bool:
"""Check if model supports streaming responses."""
...
def supports_function_calling(self, model: str) -> bool:
"""Check if model supports function/tool calling."""
...
def supports_structured_output(self, model: str) -> bool:
"""Check if model supports structured JSON output."""
...
def supports_vision(self, model: str) -> bool:
"""Check if model supports vision/image inputs."""
...
def get_max_tokens(self, model: str) -> Optional[int]:
"""Get maximum context length for model."""
...
@runtime_checkable
class LLMRateLimiterProtocol(Protocol):
"""
Protocol for LLM request rate limiting.
Enables different rate limiting strategies without
coupling to specific implementations.
"""
async def acquire(self, tokens: int = 1) -> None:
"""
Acquire permission to make request.
Args:
tokens: Number of tokens in the request
Raises:
RateLimitError: If rate limit would be exceeded
"""
...
def can_proceed(self, tokens: int = 1) -> bool:
"""
Check if request can proceed without blocking.
Args:
tokens: Number of tokens in the request
Returns:
True if request can proceed immediately
"""
...
def get_wait_time(self, tokens: int = 1) -> float:
"""
Get seconds to wait before request can proceed.
Args:
tokens: Number of tokens in the request
Returns:
Seconds to wait (0.0 if can proceed immediately)
"""
...
@runtime_checkable
class LLMFailoverProtocol(Protocol):
"""
Protocol for LLM failover strategies.
Enables automatic fallback to alternative models
when primary models fail or are unavailable.
"""
def get_fallback_model(self, failed_model: str, error: Exception) -> Optional[str]:
"""
Get fallback model for failed request.
Args:
failed_model: Model that failed
error: Exception that occurred
Returns:
Alternative model name or None if no fallback available
"""
...
def should_retry(self, error: Exception) -> bool:
"""
Check if error indicates retryable failure.
Args:
error: Exception that occurred
Returns:
True if should retry with fallback
"""
...
def get_retry_delay(self, attempt: int) -> float:
"""
Get delay before retry attempt.
Args:
attempt: Retry attempt number (1-based)
Returns:
Seconds to wait before retry
"""
...
class LLMProviderError(Exception):
"""Base exception for LLM provider errors."""
def __init__(self, message: str, provider: Optional[str] = None, model: Optional[str] = None):
self.provider = provider
self.model = model
super().__init__(message)
class RateLimitError(LLMProviderError):
"""Raised when rate limit is exceeded."""
def __init__(self, message: Optional[str] = None, retry_after: Optional[float] = None, provider: Optional[str] = None, model: Optional[str] = None):
self.retry_after = retry_after
super().__init__(message or "Rate limit exceeded", provider=provider, model=model)
class ModelNotAvailableError(LLMProviderError):
"""Raised when requested model is not available."""
def __init__(self, model: str, provider: Optional[str] = None):
message = f"Model '{model}' is not available{f' from provider {provider}' if provider else ''}"
super().__init__(message, provider=provider, model=model)
class ContextLengthExceededError(LLMProviderError):
"""Raised when input exceeds model's context length."""
def __init__(self, tokens: int, max_tokens: int, provider: Optional[str] = None, model: Optional[str] = None):
self.tokens = tokens
self.max_tokens = max_tokens
super().__init__(f"Input length ({tokens} tokens) exceeds model limit ({max_tokens} tokens)", provider=provider, model=model)
@runtime_checkable
class LLMProviderProtocol(Protocol):
"""
Protocol for provider-specific LLM adaptations.
This replaces scattered provider dispatch logic throughout the core.
Each provider implements these hooks to handle provider-specific quirks
without requiring if/elif branching in the core chat loop.
Example:
```python
class OllamaAdapter:
def supports_prompt_caching(self) -> bool:
return False
def should_summarize_tools(self, iter_count: int) -> bool:
return iter_count >= 3 # Ollama-specific threshold
def format_tools(self, tools) -> list:
return tools # No special formatting needed
def post_tool_iteration(self, state) -> None:
if state.get('empty_response') and state.get('tools'):
# Add Ollama-specific tool summary
state['summary'] = "Tool results processed"
```
"""
def supports_prompt_caching(self) -> bool:
"""Check if this provider supports prompt caching."""
...
def should_summarize_tools(self, iter_count: int) -> bool:
"""Check if tools should be summarized at this iteration count."""
...
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Format tools for this provider's requirements."""
...
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
"""Handle provider-specific post-tool processing."""
...
def supports_structured_output(self) -> bool:
"""Check if provider supports structured JSON output."""
...
def supports_streaming(self) -> bool:
"""Check if provider supports streaming responses."""
...
@runtime_checkable
class UnifiedLLMProtocol(Protocol):
"""
Unified protocol for LLM dispatch that consolidates the dual execution paths.
This replaces the separate custom-LLM path (LLM.get_response) and OpenAI path
(OpenAIClient) with a single async-first protocol that all providers implement.
Sync implementations must not call asyncio.run() from library internals.
Provide native sync implementations or convert streaming responses
to real Iterator[Dict[str, Any]] objects.
"""
async def achat_completion(
self,
messages: List[Dict[str, Any]],
*,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.0,
max_tokens: Optional[int] = None,
stream: bool = False,
**kwargs: Any,
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
"""
Primary async chat completion method.
All LLM interactions go through this single path.
"""
...
def chat_completion(
self,
messages: List[Dict[str, Any]],
**kwargs: Any
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
"""
Synchronous chat completion entry point.
For streaming, implementations must return a real Iterator[Dict[str, Any]].
"""
...
+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."""
@@ -5,15 +5,37 @@ This module provides concrete implementations of MemoryProtocol
for different backends, following the adapter pattern used in knowledge/.
Adapters:
- sqlite_adapter: SQLite-based memory storage
- chromadb_adapter: ChromaDB vector memory storage
- mongodb_adapter: MongoDB document storage
- redis_adapter: Redis in-memory storage
- postgres_adapter: PostgreSQL storage
- sqlite_adapter: SQLite-based memory storage (core)
- in_memory_adapter: Lightweight in-memory storage (core, for testing/dev)
- chromadb_adapter: ChromaDB vector memory storage (wrapper)
- mongodb_adapter: MongoDB document storage (wrapper)
- redis_adapter: Redis in-memory storage (wrapper)
- postgres_adapter: PostgreSQL storage (wrapper)
Heavy adapters (chromadb, mongodb, etc.) are moved to wrapper package
to keep core lightweight and protocol-driven.
"""
from .sqlite_adapter import SqliteMemoryAdapter
from .in_memory_adapter import InMemoryAdapter
from .registry import (
register_memory_adapter,
register_memory_factory,
get_memory_adapter,
list_memory_adapters,
get_first_available_memory_adapter,
)
# Register core adapters
register_memory_adapter("sqlite", SqliteMemoryAdapter)
register_memory_adapter("in_memory", InMemoryAdapter)
__all__ = [
'SqliteMemoryAdapter',
]
'InMemoryAdapter',
'register_memory_adapter',
'register_memory_factory',
'get_memory_adapter',
'list_memory_adapters',
'get_first_available_memory_adapter',
]
@@ -0,0 +1,73 @@
"""
In-Memory Memory Adapter for PraisonAI.
Lightweight in-memory adapter for testing and development.
No dependencies required - stores data as plain Python lists.
"""
from typing import Any, Dict, List, Optional
class InMemoryAdapter:
"""
Lightweight in-memory adapter for testing and development.
Stores short-term and long-term memories as plain Python lists.
Data is not persisted across process restarts.
Usage:
adapter = InMemoryAdapter()
adapter.store_short_term("hello", {"source": "test"})
results = adapter.search_short_term("hello")
"""
def __init__(self, **kwargs) -> None:
self._data: List[Dict[str, Any]] = []
def store_short_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
) -> str:
entry: Dict[str, Any] = {
"id": str(len(self._data)),
"text": text,
"type": "short",
"metadata": metadata,
}
self._data.append(entry)
return entry["id"]
def search_short_term(
self, query: str, limit: int = 5, **kwargs
) -> List[Dict[str, Any]]:
results = [
e
for e in self._data
if e["type"] == "short" and query.lower() in e["text"].lower()
]
return results[:limit]
def store_long_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
) -> str:
entry: Dict[str, Any] = {
"id": str(len(self._data)),
"text": text,
"type": "long",
"metadata": metadata,
}
self._data.append(entry)
return entry["id"]
def search_long_term(
self, query: str, limit: int = 5, **kwargs
) -> List[Dict[str, Any]]:
results = [
e
for e in self._data
if e["type"] == "long" and query.lower() in e["text"].lower()
]
return results[:limit]
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
# Return defensive copy to prevent external mutation of internal state
return [dict(entry) for entry in self._data]
@@ -0,0 +1,106 @@
"""
Memory Adapter Registry
Provides a registry system for memory adapters to replace hardcoded imports
in memory/memory.py. This enables protocol-driven memory backends without
core modifications.
"""
from typing import Callable, List, Optional, Tuple, Type
from ...utils.adapter_registry import AdapterRegistry
from ..protocols import MemoryProtocol
class MemoryAdapterRegistry(AdapterRegistry[MemoryProtocol]):
"""
Thread-safe registry for memory adapters.
Replaces hardcoded _check_chromadb, _check_mem0, etc. in memory.py
with dynamic adapter discovery and instantiation.
Example:
```python
# Register adapter (typically done on import)
register_memory_adapter("sqlite", SQLiteAdapter)
register_memory_adapter("chroma", ChromaAdapter) # in wrapper
# Use in Memory class
adapter = get_memory_adapter("chroma", config=config)
```
"""
def __init__(self):
super().__init__(adapter_type_name="Memory")
# Global registry instance
_memory_registry = MemoryAdapterRegistry()
def register_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
"""Register a memory adapter class."""
_memory_registry.register_adapter(name, adapter_class)
def register_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
"""Register a memory adapter factory function."""
_memory_registry.register_factory(name, factory_func)
def get_memory_adapter(name: str, **kwargs) -> Optional[MemoryProtocol]:
"""Get memory adapter instance by name."""
return _memory_registry.get_adapter(name, **kwargs)
def list_memory_adapters() -> List[str]:
"""List all registered memory adapter names."""
return _memory_registry.list_adapters()
# Canonical aliases per AGENTS.md naming conventions
def add_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
"""Canonical alias for register_memory_adapter (preferred naming per AGENTS.md)."""
return register_memory_adapter(name, adapter_class)
def add_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
"""Canonical alias for register_memory_factory (preferred naming per AGENTS.md)."""
return register_memory_factory(name, factory_func)
def has_memory_adapter(name: str) -> bool:
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
return _memory_registry.is_available(name)
def get_first_available_memory_adapter(
preferences: Optional[List[str]] = None, **kwargs
) -> Optional[Tuple[str, MemoryProtocol]]:
"""
Get first available memory adapter.
Args:
preferences: List of adapter names to try (defaults to ["sqlite", "in_memory"])
**kwargs: Configuration passed to adapter constructor
Returns:
(adapter_name, adapter_instance) tuple or None
"""
if preferences is None:
preferences = ["sqlite", "in_memory"]
return _memory_registry.get_first_available(preferences, **kwargs)
__all__ = [
'MemoryAdapterRegistry',
'register_memory_adapter',
'register_memory_factory',
'get_memory_adapter',
'list_memory_adapters',
'get_first_available_memory_adapter',
# Canonical aliases (preferred)
'add_memory_adapter',
'add_memory_factory',
'has_memory_adapter',
]
@@ -8,6 +8,7 @@ and quality scoring logic. Split from the main memory.py file for better maintai
import json
import logging
import threading
import asyncio
from typing import Any, Dict, List, Optional
from datetime import datetime
@@ -304,4 +305,86 @@ class MemoryCoreMixin:
self._learn_manager = LearnManager(config=self._learn_config)
except ImportError:
logging.warning("Learn manager not available - install learn dependencies")
return self._learn_manager
return self._learn_manager
# Async variants to prevent event loop blocking
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
"""
Async version of store_short_term to prevent event loop blocking.
Args:
content: The content to store
metadata: Optional metadata dictionary
quality_score: Optional pre-calculated quality score
user_id: Optional user identifier
auto_promote: Whether to automatically promote to LTM if quality is high
Returns:
The memory ID of the stored content
"""
if not content.strip():
return ""
# Calculate quality score if not provided
if quality_score is None:
quality_score = self.compute_quality_score(content, metadata)
# Prepare metadata (mirror sync version's metadata construction including sanitization
# to ensure only JSON-serializable values are stored, preventing crashes across backends)
raw_metadata = metadata.copy() if metadata else {}
raw_metadata.update({
"timestamp": datetime.now().isoformat(),
"quality_score": quality_score,
"memory_type": "short_term"
})
if user_id:
raw_metadata["user_id"] = user_id
clean_metadata = self._sanitize_metadata(raw_metadata)
# Store in SQLite STM
memory_id = ""
try:
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
except Exception as e:
logging.error(f"Failed to store in SQLite STM: {e}")
return ""
# Auto-promote to long-term memory if quality is high (async)
if auto_promote and quality_score >= 7.5: # High quality threshold
try:
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
except Exception as e:
logging.warning(f"Failed to auto-promote to LTM: {e}")
# Emit memory event
self._emit_memory_event("store", "short_term", content, clean_metadata)
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
return memory_id or ""
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
user_id: Optional[str] = None) -> str:
"""
Async version of store_long_term to prevent event loop blocking.
Args:
content: The content to store
metadata: Optional metadata dictionary
quality_score: Optional pre-calculated quality score
user_id: Optional user identifier
Returns:
The memory ID of the stored content
"""
if not content.strip():
return ""
# Calculate quality score if not provided
if quality_score is None:
quality_score = self.compute_quality_score(content, metadata)
# Use sync version in thread to avoid blocking event loop
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
@@ -26,12 +26,6 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, 'TRACE')
def trace(self, message, *args, **kwargs):
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
logging.Logger.trace = trace
# Thread-safe lazy imports using proper thread synchronization
import threading
@@ -942,7 +936,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
from praisonaiagents.embedding import embedding as get_embedding
logger.info("Getting embeddings...")
logger.trace(f"Embedding input text: {text}")
logger.log(TRACE_LEVEL, f"Embedding input text: {text}")
result = get_embedding(text, model=self.embedding_model)
embedding = result.embeddings[0] if result.embeddings else None
@@ -952,7 +946,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
return
logger.info("Successfully got embeddings")
logger.trace(f"Received embedding of length: {len(embedding)}")
logger.log(TRACE_LEVEL, f"Received embedding of length: {len(embedding)}")
# Sanitize metadata for ChromaDB
sanitized_metadata = self._sanitize_metadata(metadata)
@@ -115,8 +115,9 @@ def enable(plugins: list = None) -> None:
from .manager import get_plugin_manager
manager = get_plugin_manager()
# Auto-discover plugins from default directories
# Auto-discover plugins from default directories and entry points
manager.auto_discover_plugins()
manager.discover_entry_points()
# Enable specific plugins or all
if plugins is not None:
@@ -57,23 +57,24 @@ class PluginManager:
Returns:
True if registered successfully
"""
try:
info = plugin.info
if info.name in self._plugins:
logger.warning(f"Plugin '{info.name}' already registered")
with self._lock:
try:
info = plugin.info
if info.name in self._plugins:
logger.warning(f"Plugin '{info.name}' already registered")
return False
self._plugins[info.name] = plugin
self._enabled[info.name] = True
# Initialize plugin
plugin.on_init({})
logger.info(f"Registered plugin: {info.name} v{info.version}")
return True
except Exception as e:
logger.error(f"Failed to register plugin: {e}")
return False
self._plugins[info.name] = plugin
self._enabled[info.name] = True
# Initialize plugin
plugin.on_init({})
logger.info(f"Registered plugin: {info.name} v{info.version}")
return True
except Exception as e:
logger.error(f"Failed to register plugin: {e}")
return False
def unregister(self, name: str) -> bool:
"""
@@ -423,6 +424,97 @@ class PluginManager:
def get_single_file_plugin(self, name: str) -> Optional[Dict[str, Any]]:
"""Get a single-file plugin by name."""
return self._single_file_plugins.get(name)
def discover_entry_points(self) -> int:
"""
Discover plugins registered via pip entry_points.
Looks for entry_points in the "praisonaiagents.plugins" group.
Compatible with Python 3.8+ using importlib.metadata backport.
Returns:
Number of plugins loaded
"""
# Lazy import to reduce startup time
def _get_entry_points():
try:
from importlib.metadata import entry_points as _ep
return _ep
except ImportError:
logger.warning("importlib.metadata not available - cannot discover entry_points plugins")
return None
get_ep = _get_entry_points()
if get_ep is None:
return 0
try:
# Python 3.10+ style - try group= first
eps = get_ep(group="praisonaiagents.plugins")
except TypeError:
# Python 3.9 fallback - call then get group
try:
all_eps = get_ep()
eps = all_eps.get("praisonaiagents.plugins", [])
except Exception:
eps = []
loaded = 0
# Use lock to ensure thread-safe discovery of multiple entry points
with self._lock:
for ep in eps:
try:
plugin_class = ep.load()
plugin = plugin_class()
# register() also acquires lock but that's fine (RLock allows re-entry)
if self.register(plugin):
loaded += 1
logger.info(f"Loaded entry_point plugin: {ep.name}")
except Exception as e:
logger.error(f"Failed to load entry_point plugin {ep.name}: {e}")
return loaded
# =========================================================================
# Entry Point Discovery Support (External Packages)
# =========================================================================
def discover_entry_points(self) -> int:
"""
Auto-discover and load protocol-driven plugins installed via pip
that register in the 'praisonai.plugins' entry-point group.
Returns:
Number of plugins loaded successfully.
"""
import importlib.metadata
loaded = 0
try:
# Python 3.10+
entry_points = importlib.metadata.entry_points(group="praisonai.plugins")
except TypeError:
# Python 3.8/3.9 fallback
try:
entry_points = importlib.metadata.entry_points().get("praisonai.plugins", [])
except Exception as e:
logger.error(f"Failed to get entry points: {e}")
return 0
for ep in entry_points:
try:
plugin_cls = ep.load()
# Duck-type check or inheritance check isn't strictly necessary if it conforms to Plugin interface at runtime
if callable(plugin_cls):
plugin = plugin_cls()
if self.register(plugin):
loaded += 1
else:
logger.warning(f"Entry point {ep.name} is not callable")
except Exception as e:
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
return loaded
# Global plugin manager instance
_default_manager: Optional[PluginManager] = None
@@ -1,5 +1,7 @@
import logging
import asyncio
import threading
import time
import json
from typing import Dict, Optional, List, Any, AsyncGenerator
from pydantic import BaseModel, ConfigDict
@@ -25,6 +27,8 @@ class Process:
manager_llm: Optional[str] = None,
verbose: bool = False,
max_iter: int = 10,
max_retries: int = 3,
workflow_timeout: Optional[int] = None, # seconds, None = no timeout
output: Optional[str] = None,
):
logging.debug(f"=== Initializing Process ===")
@@ -37,9 +41,11 @@ class Process:
self.agents = agents
self.manager_llm = manager_llm
self.max_iter = max_iter
self.max_retries = max_retries
self.workflow_timeout = workflow_timeout
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
self.workflow_finished = False # ADDED: Workflow finished flag
self._state_lock = asyncio.Lock() # Async lock for shared state protection
self._state_lock = None # Lazy-initialized async lock for shared state protection
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
if output is not None:
@@ -233,7 +239,7 @@ class Process:
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
task.description = task.description.split('Input data from previous tasks:')[0].strip()
while fallback_attempts < Process.DEFAULT_RETRY_LIMIT and not temp_current_task:
while fallback_attempts < self.max_retries and not temp_current_task:
fallback_attempts += 1
logging.debug(f"Fallback attempt {fallback_attempts}: Trying to find next 'not started' task.")
for task_candidate in self.tasks.values():
@@ -248,13 +254,13 @@ class Process:
if not leads_to_task and not task_candidate.next_tasks:
continue # Skip if no valid path exists
if self.task_retry_counter.get(task_candidate.id, 0) < Process.DEFAULT_RETRY_LIMIT:
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
temp_current_task = task_candidate
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
return temp_current_task # Return the found task immediately
else:
logging.debug(f"Max retries reached for task {task_candidate.name} in fallback mode, marking as failed.")
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
task_candidate.status = "failed"
if not temp_current_task:
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
@@ -383,6 +389,7 @@ class Process:
"""Async version of workflow method"""
logging.debug("=== Starting Async Workflow ===")
current_iter = 0 # Track how many times we've looped
workflow_start = time.monotonic() # For timeout enforcement
# Build workflow relationships first
logging.debug("Building workflow relationships...")
for task in self.tasks.values():
@@ -418,6 +425,13 @@ class Process:
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
break
# Enforce workflow timeout if set
if self.workflow_timeout is not None:
elapsed = time.monotonic() - workflow_start
if elapsed > self.workflow_timeout:
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
break
# ADDED: Check workflow finished flag at the start of each cycle
if self.workflow_finished:
logging.info("Workflow finished early as all tasks are completed.")
@@ -455,11 +469,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
Description length: {len(current_task.description)}
""")
# Add context from previous tasks to description
# Build context and set description for this execution pass only
context = self._build_task_context(current_task)
if context:
# Update task description with context
current_task.description = current_task.description + context
# Store original description if not already stored
if not hasattr(current_task, '_original_description'):
current_task._original_description = current_task.description
# Set description with context for execution; reset after yield to prevent accumulation
current_task.description = current_task._original_description + (context if context else "")
# Skip execution for loop tasks, only process their subtasks
if current_task.task_type == "loop":
@@ -566,6 +582,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -577,28 +596,31 @@ Subtask: {st.name}
current_task = None
break
# Reset completed task to "not started" so it can run again
if self.tasks[task_id].status == "completed":
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
subtask_name = self.tasks[task_id].name
task_to_check = self.tasks[task_id]
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
logging.debug(f"Task type: {task_to_check.task_type}")
logging.debug(f"Task status before reset check: {task_to_check.status}")
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
# Reset completed task to "not started" so it can run again (atomic operation)
if self._state_lock is None:
self._state_lock = asyncio.Lock()
async with self._state_lock:
if self.tasks[task_id].status == "completed":
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
subtask_name = self.tasks[task_id].name
task_to_check = self.tasks[task_id]
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
logging.debug(f"Task type: {task_to_check.task_type}")
logging.debug(f"Task status before reset check: {task_to_check.status}")
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
for t in self.tasks.values()) and
not task_to_check.async_execution): # Don't reset async parallel tasks
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
self.tasks[task_id].status = "not started"
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
else:
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
for t in self.tasks.values()) and
not task_to_check.async_execution): # Don't reset async parallel tasks
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
self.tasks[task_id].status = "not started"
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
else:
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
# Handle loop progression
if current_task.task_type == "loop":
@@ -1116,11 +1138,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
Description length: {len(current_task.description)}
""")
# Add context from previous tasks to description
# Build context and set description for this execution pass only
context = self._build_task_context(current_task)
if context:
# Update task description with context
current_task.description = current_task.description + context
# Store original description if not already stored
if not hasattr(current_task, '_original_description'):
current_task._original_description = current_task.description
# Set description with context for execution; reset after yield to prevent accumulation
current_task.description = current_task._original_description + (context if context else "")
# Skip execution for loop tasks, only process their subtasks
if current_task.task_type == "loop":
@@ -1227,6 +1251,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -118,6 +118,67 @@ class RAGProtocol(Protocol):
...
@runtime_checkable
class RetrievalStrategyProtocol(Protocol):
"""
Protocol for pluggable retrieval strategies.
Enables custom retrieval algorithms without modifying core RAG pipeline.
Implementations can provide different retrieval approaches (semantic,
keyword, hybrid, graph-based, etc.) through a unified interface.
"""
name: str
def retrieve(
self,
query: str,
knowledge_store: Any, # KnowledgeStoreProtocol
*,
limit: int = 10,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[Dict[str, Any]]:
"""
Retrieve relevant documents for query.
Args:
query: Search query string
knowledge_store: Knowledge store to search
limit: Maximum number of results
filters: Optional metadata filters
**kwargs: Strategy-specific options
Returns:
List of retrieved documents with metadata
"""
...
async def aretrieve(
self,
query: str,
knowledge_store: Any, # KnowledgeStoreProtocol
*,
limit: int = 10,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[Dict[str, Any]]:
"""
Async version of retrieve.
Args:
query: Search query string
knowledge_store: Knowledge store to search
limit: Maximum number of results
filters: Optional metadata filters
**kwargs: Strategy-specific options
Returns:
List of retrieved documents with metadata
"""
...
@runtime_checkable
class GraphHookProtocol(Protocol):
"""
@@ -640,6 +640,7 @@ class MinimalTelemetry:
# Global telemetry instance
_telemetry_instance = None
_telemetry_instance_lock = threading.Lock()
def get_telemetry() -> MinimalTelemetry:
"""
@@ -649,17 +650,19 @@ def get_telemetry() -> MinimalTelemetry:
The global MinimalTelemetry instance
"""
global _telemetry_instance
if _telemetry_instance is None:
_telemetry_instance = MinimalTelemetry()
return _telemetry_instance
with _telemetry_instance_lock:
if _telemetry_instance is None:
_telemetry_instance = MinimalTelemetry()
return _telemetry_instance
def disable_telemetry():
"""Programmatically disable telemetry."""
global _telemetry_instance
if _telemetry_instance:
_telemetry_instance.enabled = False
else:
_telemetry_instance = MinimalTelemetry(enabled=False)
with _telemetry_instance_lock:
if _telemetry_instance:
_telemetry_instance.enabled = False
else:
_telemetry_instance = MinimalTelemetry(enabled=False)
def force_shutdown_telemetry():
"""
@@ -667,8 +670,9 @@ def force_shutdown_telemetry():
This function ensures proper termination of all background threads.
"""
global _telemetry_instance
if _telemetry_instance:
_telemetry_instance.shutdown()
with _telemetry_instance_lock:
if _telemetry_instance:
_telemetry_instance.shutdown()
# Additional cleanup - wait for all threads to finish
import threading
@@ -698,11 +702,12 @@ def force_shutdown_telemetry():
def enable_telemetry():
"""Programmatically enable telemetry (if not disabled by environment)."""
global _telemetry_instance
if not _is_telemetry_disabled():
if _telemetry_instance:
_telemetry_instance.enabled = True
else:
_telemetry_instance = MinimalTelemetry(enabled=True)
with _telemetry_instance_lock:
if not _is_telemetry_disabled():
if _telemetry_instance:
_telemetry_instance.enabled = True
else:
_telemetry_instance = MinimalTelemetry(enabled=True)
# For backward compatibility with existing code
class TelemetryCollector:
@@ -200,7 +200,16 @@ TOOL_MAPPINGS = {
'email_tools': ('.email_tools', None),
}
_instances = {} # Cache for class instances (protected by _tools_lock)
# Tool factory functions - caches classes but creates fresh instances
# This prevents state leakage between concurrent agents while optimizing import performance
_loaded_classes = {} # Cache the Class, NOT the instance
def _create_tool_instance(class_name: str, module_path: str):
"""Create a new tool instance. Caches the class but returns fresh instances to prevent state sharing."""
if class_name not in _loaded_classes:
module = import_module(module_path, __package__)
_loaded_classes[class_name] = getattr(module, class_name)
return _loaded_classes[class_name]() # Fresh instance safe for multi-agent
# Profile exports (lazy loaded)
_PROFILE_EXPORTS = frozenset({
@@ -325,17 +334,10 @@ def __getattr__(name: str) -> Any:
return module # Returns the callable module
return getattr(module, name)
else:
# Class method import (thread-safe)
if class_name not in _instances:
with _tools_lock:
# Double-check pattern to avoid race conditions
if class_name not in _instances:
module = import_module(module_path, __package__)
class_ = getattr(module, class_name)
_instances[class_name] = class_()
# Get the method and bind it to the instance
method = getattr(_instances[class_name], name)
# Create a fresh tool instance for each agent/session to prevent state leakage
# This factory pattern ensures multi-agent safety by avoiding shared mutable state
instance = _create_tool_instance(class_name, module_path)
method = getattr(instance, name)
return method
__all__ = list(TOOL_MAPPINGS.keys()) + [
@@ -434,8 +434,18 @@ class CircuitBreakerRegistry:
self._breakers.clear()
# Global registry instance for convenience
_global_registry = CircuitBreakerRegistry()
# Global registry instance for convenience (lazy initialization)
_global_registry = None
_global_registry_lock = threading.Lock()
def _get_global_registry() -> CircuitBreakerRegistry:
"""Get or create the global circuit breaker registry."""
global _global_registry
with _global_registry_lock:
if _global_registry is None:
_global_registry = CircuitBreakerRegistry()
return _global_registry
def get_circuit_breaker(
@@ -455,14 +465,14 @@ def get_circuit_breaker(
Returns:
CircuitBreaker instance
"""
return _global_registry.get_or_create(name, config, health_check, fallback)
return _get_global_registry().get_or_create(name, config, health_check, fallback)
def get_all_circuit_breaker_stats() -> Dict[str, Dict[str, Any]]:
"""Get statistics for all circuit breakers in the global registry."""
return _global_registry.get_all_stats()
return _get_global_registry().get_all_stats()
def reset_all_circuit_breakers() -> None:
"""Reset all circuit breakers in the global registry."""
_global_registry.reset_all()
_get_global_registry().reset_all()
@@ -39,17 +39,20 @@ class FileTools:
if filepath.startswith('~'):
filepath = os.path.expanduser(filepath)
# Normalize the path
normalized = os.path.normpath(filepath)
absolute = os.path.abspath(normalized)
# Check for path traversal attempts (.. after normalization)
# Check for path traversal attempts BEFORE normalization
# We check the original input for '..' to catch traversal attempts
if '..' in normalized:
if '..' in filepath:
raise ValueError(f"Path traversal detected: {filepath}")
# Additional check: ensure the resolved path doesn't escape expected boundaries
# This is a basic check - in production, you'd want to define allowed directories
# Normalize the path and securely resolve symlinks
normalized = os.path.normpath(filepath)
absolute = os.path.realpath(normalized)
# Prevent path traversal outside current workspace / allowed directories
cwd = os.getcwd()
if not absolute.startswith(cwd):
raise ValueError(f"Path traversal detected: {filepath} escapes workspace {cwd}")
return absolute
@staticmethod
@@ -327,7 +330,7 @@ class FileTools:
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
# Download file
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
with httpx.stream("GET", url, timeout=timeout, follow_redirects=False) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
@@ -455,9 +455,14 @@ def get_circuit_breaker_dashboard_data() -> Dict[str, Any]:
return dashboard_data
# Global health monitor instance for convenience
_global_health_monitor = HealthMonitor()
# Global health monitor instance for convenience (lazy initialization)
_global_health_monitor = None
_global_health_monitor_lock = threading.Lock()
def get_health_monitor() -> HealthMonitor:
"""Get the global health monitor instance."""
return _global_health_monitor
global _global_health_monitor
with _global_health_monitor_lock:
if _global_health_monitor is None:
_global_health_monitor = HealthMonitor()
return _global_health_monitor
@@ -143,7 +143,15 @@ def safe_execute():
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__'
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
}}
for node in ast.walk(tree):
@@ -162,7 +170,9 @@ def safe_execute():
"success": False
}}
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
return {{
"result": None,
"stdout": "",
@@ -17,6 +17,7 @@ Usage:
"""
import logging
import threading
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -24,6 +25,7 @@ logger = get_logger(__name__)
# ── lazy singleton store ─────────────────────────────────────────────────────
_store_instance = None
_store_instance_lock = threading.Lock()
def set_store(store):
"""Inject an external schedule store (e.g., config.yaml-backed).
@@ -33,7 +35,8 @@ def set_store(store):
``config.yaml`` instead of the default ``jobs.json``.
"""
global _store_instance
_store_instance = store
with _store_instance_lock:
_store_instance = store
def _get_store():
"""Return (or create) the global schedule store.
@@ -42,11 +45,12 @@ def _get_store():
Automatically migrates any existing ``jobs.json`` data on first use.
"""
global _store_instance
if _store_instance is None:
from ..scheduler.config_store import ConfigYamlScheduleStore
_store_instance = ConfigYamlScheduleStore()
_store_instance.migrate_from_json()
return _store_instance
with _store_instance_lock:
if _store_instance is None:
from ..scheduler.config_store import ConfigYamlScheduleStore
_store_instance = ConfigYamlScheduleStore()
_store_instance.migrate_from_json()
return _store_instance
# ── tools ────────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@ Schema Version: 1.0
import json
import time
import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@@ -388,17 +389,20 @@ class TraceEmitter:
# Global default emitter (NoOp by default)
_default_emitter: Optional[TraceEmitter] = None
_default_emitter_lock = threading.Lock()
def get_default_emitter() -> TraceEmitter:
"""Get the default trace emitter."""
global _default_emitter
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
with _default_emitter_lock:
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
def set_default_emitter(emitter: TraceEmitter) -> None:
"""Set the default trace emitter."""
global _default_emitter
_default_emitter = emitter
with _default_emitter_lock:
_default_emitter = emitter
@@ -0,0 +1,108 @@
"""
Generic Adapter Registry
Provides a thread-safe, generic registry for protocol-based adapters.
Used by memory/adapters/registry.py and knowledge/adapters/registry.py to
avoid code duplication.
"""
import logging
import threading
from typing import Callable, Dict, Generic, List, Optional, Tuple, Type, TypeVar
T = TypeVar("T")
logger = logging.getLogger(__name__)
class AdapterRegistry(Generic[T]):
"""
Thread-safe generic registry for protocol-based adapters.
Supports two registration modes:
- **class**: instantiated on demand via ``register_adapter``
- **factory**: called on demand via ``register_factory``
Factory entries take priority over class entries when both exist for the
same name, because factories can apply extra configuration logic.
"""
def __init__(self, adapter_type_name: str = "adapter") -> None:
self._adapters: Dict[str, Type[T]] = {}
self._factories: Dict[str, Callable[..., T]] = {}
self._lock = threading.Lock()
self._type_name = adapter_type_name
def register_adapter(self, name: str, adapter_class: Type[T]) -> None:
"""Register an adapter class instantiated on demand."""
with self._lock:
self._adapters[name] = adapter_class
def register_factory(self, name: str, factory_func: Callable[..., T]) -> None:
"""Register a factory function that creates adapter instances."""
with self._lock:
self._factories[name] = factory_func
def get_adapter(self, name: str, **kwargs) -> Optional[T]:
"""
Return an adapter instance for *name*, or ``None`` if unavailable.
Tries the factory first (if registered), then falls back to direct
class instantiation. Lock is only held for lookup, not construction
to avoid deadlocks and blocking registry access.
"""
# Only hold lock to look up the factory/class, not during instantiation
with self._lock:
factory = self._factories.get(name)
adapter_cls = self._adapters.get(name)
# Try factory first (instantiate outside lock)
if factory is not None:
try:
return factory(**kwargs)
except Exception as exc:
raise RuntimeError(
f"Failed to create {self._type_name} adapter '{name}' via factory. "
"Check adapter config and installed extras."
) from exc
# Try direct class instantiation (outside lock)
if adapter_cls is not None:
try:
return adapter_cls(**kwargs)
except Exception as exc:
raise RuntimeError(
f"Failed to instantiate {self._type_name} adapter '{name}'. "
"Check adapter constructor args and installed extras."
) from exc
return None
def list_adapters(self) -> List[str]:
"""Return a sorted list of all registered adapter names."""
with self._lock:
return sorted(set(self._adapters.keys()) | set(self._factories.keys()))
def is_available(self, name: str) -> bool:
"""Return ``True`` if *name* has a registered adapter or factory."""
with self._lock:
return name in self._adapters or name in self._factories
def get_first_available(
self, preferences: List[str], **kwargs
) -> Optional[Tuple[str, T]]:
"""
Return the first successfully instantiated adapter from *preferences*.
Args:
preferences: Adapter names tried in order.
**kwargs: Passed to the adapter constructor / factory.
Returns:
``(name, instance)`` tuple, or ``None`` if none could be created.
"""
for name in preferences:
instance = self.get_adapter(name, **kwargs)
if instance is not None:
return name, instance
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.112"
version = "1.5.117"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -363,3 +363,92 @@ class TestGlobalPluginManager:
manager2 = get_plugin_manager()
assert manager1 is manager2
class TestEntryPointsDiscovery:
"""Tests for entry_points plugin discovery."""
def test_discover_entry_points_no_plugins(self, monkeypatch):
"""Test entry_points discovery with no plugins installed."""
import importlib.metadata as metadata
def mock_entry_points(*args, **kwargs):
if kwargs.get("group") == "praisonaiagents.plugins":
return []
return {"praisonaiagents.plugins": []}
monkeypatch.setattr(metadata, "entry_points", mock_entry_points)
manager = PluginManager()
# Should return 0 when no entry_points exist for our group
loaded = manager.discover_entry_points()
assert loaded == 0
def test_discover_entry_points_import_error(self, monkeypatch):
"""Test graceful handling when importlib.metadata is not available."""
import sys
# Remove importlib.metadata from sys.modules to simulate import failure
original_module = sys.modules.pop("importlib.metadata", None)
# Mock the import to raise ImportError
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "importlib.metadata":
raise ImportError("No module named importlib.metadata")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
try:
manager = PluginManager()
loaded = manager.discover_entry_points()
assert loaded == 0
finally:
# Restore the module if it existed
if original_module is not None:
sys.modules["importlib.metadata"] = original_module
def test_discover_entry_points_success(self, monkeypatch):
"""Test successful entry_points discovery and plugin loading."""
import importlib.metadata as metadata
from praisonaiagents.plugins.base import Plugin, PluginInfo
# Create a mock plugin class
class MockPlugin(Plugin):
@property
def info(self) -> PluginInfo:
return PluginInfo(
name="test_plugin",
version="1.0.0",
description="Test plugin"
)
# Create a mock entry point
class MockEntryPoint:
def __init__(self, name, plugin_class):
self.name = name
self._plugin_class = plugin_class
def load(self):
return self._plugin_class
mock_ep = MockEntryPoint("test_plugin", MockPlugin)
def mock_entry_points(*args, **kwargs):
if kwargs.get("group") == "praisonaiagents.plugins":
return [mock_ep]
return {"praisonaiagents.plugins": [mock_ep]}
monkeypatch.setattr(metadata, "entry_points", mock_entry_points)
manager = PluginManager()
loaded = manager.discover_entry_points()
assert loaded == 1
assert "test_plugin" in manager._plugins
assert manager._plugins["test_plugin"].__class__ == MockPlugin
@@ -0,0 +1,390 @@
"""
Unit tests for the new protocol-driven architecture components added in issue #1302.
Tests cover:
- AdapterRegistry base class (generic, thread-safe)
- MemoryAdapterRegistry
- KnowledgeAdapterRegistry
- InMemoryAdapter
- LLM provider adapters (DefaultAdapter, OllamaAdapter, AnthropicAdapter, GeminiAdapter)
- get_provider_adapter() dispatch function
"""
import pytest
import threading
from typing import Optional
# ---------------------------------------------------------------------------
# AdapterRegistry
# ---------------------------------------------------------------------------
class TestAdapterRegistry:
"""Tests for the generic AdapterRegistry base class."""
def _make_registry(self):
from praisonaiagents.utils.adapter_registry import AdapterRegistry
return AdapterRegistry(adapter_type_name="Test")
def test_register_and_get_adapter(self):
reg = self._make_registry()
class DummyAdapter:
def __init__(self, **kwargs):
pass
reg.register_adapter("dummy", DummyAdapter)
instance = reg.get_adapter("dummy")
assert isinstance(instance, DummyAdapter)
def test_register_and_get_factory(self):
reg = self._make_registry()
class FactoryAdapter:
def __init__(self, x=0):
self.x = x
reg.register_factory("factory", lambda **kw: FactoryAdapter(x=kw.get("x", 42)))
instance = reg.get_adapter("factory", x=99)
assert instance.x == 99
def test_factory_takes_priority_over_class(self):
"""Factory should be tried before class when both are registered."""
reg = self._make_registry()
results = []
class ClassAdapter:
def __init__(self, **kw):
results.append("class")
reg.register_adapter("both", ClassAdapter)
reg.register_factory("both", lambda **kw: results.append("factory") or ClassAdapter())
reg.get_adapter("both")
assert results[0] == "factory"
def test_returns_none_for_unknown_name(self):
reg = self._make_registry()
assert reg.get_adapter("nonexistent") is None
def test_returns_none_when_factory_raises(self):
reg = self._make_registry()
def bad_factory(**kw):
raise RuntimeError("intentional failure")
reg.register_factory("bad", bad_factory)
assert reg.get_adapter("bad") is None
def test_returns_none_when_class_raises(self):
reg = self._make_registry()
class BadAdapter:
def __init__(self, **kw):
raise ValueError("intentional failure")
reg.register_adapter("bad", BadAdapter)
assert reg.get_adapter("bad") is None
def test_list_adapters_sorted(self):
reg = self._make_registry()
class A:
pass
reg.register_adapter("b_adapter", A)
reg.register_adapter("a_adapter", A)
reg.register_factory("c_factory", lambda **kw: A())
names = reg.list_adapters()
assert names == sorted(names)
assert set(names) == {"a_adapter", "b_adapter", "c_factory"}
def test_is_available(self):
reg = self._make_registry()
class A:
pass
assert not reg.is_available("x")
reg.register_adapter("x", A)
assert reg.is_available("x")
def test_get_first_available_returns_first_match(self):
reg = self._make_registry()
class A:
pass
class B:
pass
reg.register_adapter("b", B)
result = reg.get_first_available(["a", "b"])
assert result is not None
name, instance = result
assert name == "b"
assert isinstance(instance, B)
def test_get_first_available_returns_none_if_all_fail(self):
reg = self._make_registry()
assert reg.get_first_available(["x", "y"]) is None
def test_thread_safety(self):
"""Concurrent registration and reads should not cause data races."""
from praisonaiagents.utils.adapter_registry import AdapterRegistry
reg = AdapterRegistry(adapter_type_name="ThreadTest")
errors = []
class TAdapter:
def __init__(self, **kw):
pass
def writer():
try:
for i in range(50):
reg.register_adapter(f"t{i}", TAdapter)
except Exception as e:
errors.append(e)
def reader():
try:
for _ in range(50):
reg.list_adapters()
reg.get_adapter("t0")
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer), threading.Thread(target=reader)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread safety errors: {errors}"
# ---------------------------------------------------------------------------
# MemoryAdapterRegistry
# ---------------------------------------------------------------------------
class TestMemoryAdapterRegistry:
def test_imports(self):
from praisonaiagents.memory.adapters.registry import (
MemoryAdapterRegistry,
register_memory_adapter,
get_memory_adapter,
list_memory_adapters,
get_first_available_memory_adapter,
)
def test_core_adapters_registered(self):
from praisonaiagents.memory.adapters import list_memory_adapters
adapters = list_memory_adapters()
assert "sqlite" in adapters
assert "in_memory" in adapters
def test_get_in_memory_adapter(self):
from praisonaiagents.memory.adapters import get_memory_adapter
adapter = get_memory_adapter("in_memory")
assert adapter is not None
def test_get_first_available_defaults(self):
from praisonaiagents.memory.adapters import get_first_available_memory_adapter
result = get_first_available_memory_adapter()
assert result is not None
name, instance = result
assert name in ("sqlite", "in_memory")
def test_custom_registry_isolation(self):
"""Custom MemoryAdapterRegistry instances are independent."""
from praisonaiagents.memory.adapters.registry import MemoryAdapterRegistry
reg = MemoryAdapterRegistry()
class MyAdapter:
def __init__(self, **kw):
pass
reg.register_adapter("custom", MyAdapter)
assert reg.is_available("custom")
# The global registry should NOT see our custom adapter
from praisonaiagents.memory.adapters.registry import list_memory_adapters
assert "custom" not in list_memory_adapters()
# ---------------------------------------------------------------------------
# KnowledgeAdapterRegistry
# ---------------------------------------------------------------------------
class TestKnowledgeAdapterRegistry:
def test_imports(self):
from praisonaiagents.knowledge.adapters.registry import (
KnowledgeAdapterRegistry,
register_knowledge_adapter,
get_knowledge_adapter,
list_knowledge_adapters,
get_first_available_knowledge_adapter,
)
def test_register_and_retrieve(self):
from praisonaiagents.knowledge.adapters.registry import (
KnowledgeAdapterRegistry,
)
reg = KnowledgeAdapterRegistry()
class FakeStore:
def __init__(self, **kw):
pass
reg.register_adapter("fake", FakeStore)
instance = reg.get_adapter("fake")
assert isinstance(instance, FakeStore)
# ---------------------------------------------------------------------------
# InMemoryAdapter
# ---------------------------------------------------------------------------
class TestInMemoryAdapter:
def _adapter(self):
from praisonaiagents.memory.adapters.in_memory_adapter import InMemoryAdapter
return InMemoryAdapter()
def test_store_and_search_short_term(self):
a = self._adapter()
a.store_short_term("hello world", {"tag": "test"})
results = a.search_short_term("hello")
assert len(results) == 1
assert results[0]["text"] == "hello world"
def test_store_and_search_long_term(self):
a = self._adapter()
a.store_long_term("important fact", {"tag": "lm"})
results = a.search_long_term("important")
assert len(results) == 1
assert results[0]["type"] == "long"
def test_search_respects_limit(self):
a = self._adapter()
for i in range(10):
a.store_short_term(f"item {i}")
results = a.search_short_term("item", limit=3)
assert len(results) == 3
def test_short_term_not_in_long_term_search(self):
a = self._adapter()
a.store_short_term("cross contamination")
results = a.search_long_term("cross")
assert len(results) == 0
def test_get_all_memories(self):
a = self._adapter()
a.store_short_term("s1")
a.store_long_term("l1")
all_mem = a.get_all_memories()
assert len(all_mem) == 2
def test_case_insensitive_search(self):
a = self._adapter()
a.store_short_term("Hello World")
results = a.search_short_term("hello")
assert len(results) == 1
# ---------------------------------------------------------------------------
# LLM Provider Adapters
# ---------------------------------------------------------------------------
class TestLLMProviderAdapters:
def test_imports(self):
from praisonaiagents.llm.adapters import (
DefaultAdapter,
OllamaAdapter,
AnthropicAdapter,
GeminiAdapter,
get_provider_adapter,
)
def test_default_adapter_implements_protocol(self):
from praisonaiagents.llm.adapters import DefaultAdapter
from praisonaiagents.llm.protocols import LLMProviderAdapter
adapter = DefaultAdapter()
assert isinstance(adapter, LLMProviderAdapter)
def test_ollama_adapter_implements_protocol(self):
from praisonaiagents.llm.adapters import OllamaAdapter
from praisonaiagents.llm.protocols import LLMProviderAdapter
adapter = OllamaAdapter()
assert isinstance(adapter, LLMProviderAdapter)
def test_anthropic_adapter_prompt_caching(self):
from praisonaiagents.llm.adapters import AnthropicAdapter
adapter = AnthropicAdapter()
assert adapter.supports_prompt_caching() is True
def test_default_adapter_no_prompt_caching(self):
from praisonaiagents.llm.adapters import DefaultAdapter
adapter = DefaultAdapter()
assert adapter.supports_prompt_caching() is False
def test_ollama_threshold_matches_production(self):
"""OllamaAdapter.should_summarize_tools threshold must match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD."""
from praisonaiagents.llm.adapters import OllamaAdapter
from praisonaiagents.llm.llm import LLM
adapter = OllamaAdapter()
threshold = LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD
# At threshold the adapter must return True
assert adapter.should_summarize_tools(threshold) is True
# Before threshold it must return False
if threshold > 0:
assert adapter.should_summarize_tools(threshold - 1) is False
def test_gemini_adapter_formats_internal_tools(self):
"""GeminiAdapter must recognise the actual Gemini internal tool names."""
from praisonaiagents.llm.adapters import GeminiAdapter
from praisonaiagents.llm.model_capabilities import GEMINI_INTERNAL_TOOLS
adapter = GeminiAdapter()
tools = [{"name": name} for name in GEMINI_INTERNAL_TOOLS]
tools.append({"name": "regular_tool"})
formatted = adapter.format_tools(tools)
internal = [t for t in formatted if "function" in t]
regular = [t for t in formatted if "function" not in t]
assert len(internal) == len(GEMINI_INTERNAL_TOOLS)
assert len(regular) == 1
assert regular[0]["name"] == "regular_tool"
class TestGetProviderAdapter:
def test_claude_model_returns_anthropic_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, AnthropicAdapter
assert isinstance(get_provider_adapter("claude-3.5-sonnet"), AnthropicAdapter)
def test_anthropic_in_name_returns_anthropic_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, AnthropicAdapter
assert isinstance(get_provider_adapter("anthropic/claude-instant"), AnthropicAdapter)
def test_ollama_prefix_returns_ollama_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, OllamaAdapter
assert isinstance(get_provider_adapter("ollama/llama3"), OllamaAdapter)
def test_ollama_in_name_returns_ollama_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, OllamaAdapter
assert isinstance(get_provider_adapter("my-ollama-model"), OllamaAdapter)
def test_gemini_prefix_returns_gemini_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, GeminiAdapter
assert isinstance(get_provider_adapter("gemini-pro"), GeminiAdapter)
def test_gemini_slash_prefix_returns_gemini_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, GeminiAdapter
assert isinstance(get_provider_adapter("gemini/gemini-2.0-flash"), GeminiAdapter)
def test_unknown_model_returns_default_adapter(self):
from praisonaiagents.llm.adapters import get_provider_adapter, DefaultAdapter
assert isinstance(get_provider_adapter("gpt-4o"), DefaultAdapter)
+1 -1
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.112"
version = "1.5.117"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+119 -49
View File
@@ -1,14 +1,14 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-01",
"lastUpdated": "2026-04-08",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 314,
"pythonCoreFeatures": 321,
"rustFeatures": 667,
"gapCount": 36,
"parityPercentage": 212.4
"gapCount": 42,
"parityPercentage": 207.8
},
"pythonCoreSDK": {
"exports": [
@@ -80,6 +80,7 @@
"EmbeddingAgent",
"EmbeddingConfig",
"EmbeddingResult",
"ErrorContextProtocol",
"EscalationPipeline",
"EscalationStage",
"EventType",
@@ -124,6 +125,7 @@
"KNOWLEDGE_PRESETS",
"Knowledge",
"KnowledgeConfig",
"LLMError",
"LLMGuardrail",
"LLMPluginProtocol",
"LearnBackend",
@@ -152,6 +154,7 @@
"MultiAgentMemoryConfig",
"MultiAgentOutputConfig",
"MultiAgentPlanningConfig",
"NetworkError",
"OCRAgent",
"OCRConfig",
"OUTPUT_PRESETS",
@@ -176,6 +179,7 @@
"PluginParseError",
"PluginProtocol",
"PluginsConfig",
"PraisonAIError",
"PraisonConfig",
"PromptExpanderAgent",
"Provider",
@@ -223,6 +227,7 @@
"TemplateConfig",
"TodoItem",
"TodoList",
"ToolExecutionError",
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
@@ -230,6 +235,7 @@
"Tools",
"TraceSink",
"TraceSinkProtocol",
"ValidationError",
"VideoAgent",
"VideoConfig",
"VisionAgent",
@@ -292,6 +298,7 @@
"memory",
"obs",
"parallel",
"parallel_handoffs",
"parse_plugin_header",
"parse_plugin_header_from_file",
"parse_policy_string",
@@ -1796,15 +1803,6 @@
"status": "TODO",
"category": "tools"
},
{
"feature": "BudgetExceededError",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "agent"
},
{
"feature": "CodeAgent",
"python": true,
@@ -1940,33 +1938,6 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffCycleError",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffDepthError",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffError",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffInputData",
"python": true,
@@ -1985,15 +1956,6 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffTimeoutError",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "ImageAgent",
"python": true,
@@ -2237,6 +2199,15 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "parallel_handoffs",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "agent"
},
{
"feature": "prompt_with_handoff_instructions",
"python": true,
@@ -3071,6 +3042,15 @@
"status": "DONE",
"category": "bots"
},
{
"feature": "BudgetExceededError",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "CACHING_PRESETS",
"python": true,
@@ -3242,6 +3222,15 @@
"status": "DONE",
"category": "embedding"
},
{
"feature": "ErrorContextProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "EscalationPipeline",
"python": true,
@@ -3413,6 +3402,42 @@
"status": "DONE",
"category": "config"
},
{
"feature": "HandoffCycleError",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffDepthError",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffError",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffTimeoutError",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "Heartbeat",
"python": true,
@@ -3467,6 +3492,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "LLMError",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "LLMPluginProtocol",
"python": true,
@@ -3647,6 +3681,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "NetworkError",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "OUTPUT_PRESETS",
"python": true,
@@ -3782,6 +3825,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "PraisonAIError",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "PraisonConfig",
"python": true,
@@ -3953,6 +4005,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "ToolExecutionError",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "ToolPluginProtocol",
"python": true,
@@ -3980,6 +4041,15 @@
"status": "DONE",
"category": "trace"
},
{
"feature": "ValidationError",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "WEB_PRESETS",
"python": true,
+10 -4
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 314 | **Rust Features:** 667 | **Parity:** 90.8%
> **Python Features:** 321 | **Rust Features:** 667 | **Parity:** 89.1%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 314 |
| Python Core Features | 321 |
| Rust Features | 667 |
| **Actual Gap Count** | **29** |
| **Actual Gap Count** | **35** |
| Language Limitations (N/A) | 6 |
| **Parity** | **90.8%** |
| **Parity** | **89.1%** |
## Implemented Features
@@ -703,10 +703,12 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `BudgetExceededError`
- ❌ `ConsoleBackend`
- ❌ `DoomLoopDetector`
- ❌ `ErrorContextProtocol`
- ❌ `EscalationPipeline`
- ❌ `EscalationStage`
- ❌ `Heartbeat`
- ❌ `HeartbeatConfig`
- ❌ `LLMError`
- ❌ `LearnBackend`
- ❌ `LearnConfig`
- ❌ `LearnManager`
@@ -714,14 +716,18 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `LearnMode`
- ❌ `LearnProtocol`
- ❌ `LearnScope`
- ❌ `NetworkError`
- ❌ `ObservabilityEventType`
- ❌ `ObservabilityHooks`
- ❌ `PraisonAIError`
- ❌ `StructuredFormatter`
- ❌ `ToolExecutionError`
- ❌ `ToolValidationError`
- ❌ `configure_structured_logging`
- ❌ `get_logger`
- ❌ `get_registry`
- ❌ `get_tool`
- ❌ `parallel_handoffs`
- ❌ `register_tool`
- ❌ `validate_tool`
+132 -55
View File
@@ -1,17 +1,17 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-01",
"lastUpdated": "2026-04-08",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
"pythonCoreFeatures": 314,
"pythonCoreFeatures": 321,
"pythonWrapperFeatures": 99,
"typescriptFeatures": 1192,
"gapCount": 21,
"priorityP0": 0,
"gapCount": 28,
"priorityP0": 1,
"priorityP1": 0,
"priorityP2": 0,
"priorityP3": 21
"priorityP3": 27
},
"pythonCoreSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
@@ -84,6 +84,7 @@
"EmbeddingAgent",
"EmbeddingConfig",
"EmbeddingResult",
"ErrorContextProtocol",
"EscalationPipeline",
"EscalationStage",
"EventType",
@@ -128,6 +129,7 @@
"KNOWLEDGE_PRESETS",
"Knowledge",
"KnowledgeConfig",
"LLMError",
"LLMGuardrail",
"LLMPluginProtocol",
"LearnBackend",
@@ -156,6 +158,7 @@
"MultiAgentMemoryConfig",
"MultiAgentOutputConfig",
"MultiAgentPlanningConfig",
"NetworkError",
"OCRAgent",
"OCRConfig",
"OUTPUT_PRESETS",
@@ -180,6 +183,7 @@
"PluginParseError",
"PluginProtocol",
"PluginsConfig",
"PraisonAIError",
"PraisonConfig",
"PromptExpanderAgent",
"Provider",
@@ -227,6 +231,7 @@
"TemplateConfig",
"TodoItem",
"TodoList",
"ToolExecutionError",
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
@@ -234,6 +239,7 @@
"Tools",
"TraceSink",
"TraceSinkProtocol",
"ValidationError",
"VideoAgent",
"VideoConfig",
"VisionAgent",
@@ -296,6 +302,7 @@
"memory",
"obs",
"parallel",
"parallel_handoffs",
"parse_plugin_header",
"parse_plugin_header_from_file",
"parse_policy_string",
@@ -336,7 +343,6 @@
"Agent",
"AudioAgent",
"AudioConfig",
"BudgetExceededError",
"CodeAgent",
"CodeConfig",
"CodeExecutionStep",
@@ -351,12 +357,8 @@
"FileSearchCall",
"Handoff",
"HandoffConfig",
"HandoffCycleError",
"HandoffDepthError",
"HandoffError",
"HandoffInputData",
"HandoffResult",
"HandoffTimeoutError",
"ImageAgent",
"MCPCall",
"OCRAgent",
@@ -378,6 +380,7 @@
"create_context_agent",
"handoff",
"handoff_filters",
"parallel_handoffs",
"prompt_with_handoff_instructions"
],
"agents": [
@@ -567,17 +570,28 @@
"Agents",
"AsyncLearnProtocol",
"AutonomyConfig",
"BudgetExceededError",
"DoomLoopDetector",
"ErrorContextProtocol",
"EscalationPipeline",
"EscalationStage",
"HandoffCycleError",
"HandoffDepthError",
"HandoffError",
"HandoffTimeoutError",
"Heartbeat",
"HeartbeatConfig",
"LLMError",
"LearnManager",
"LearnManagerProtocol",
"LearnProtocol",
"NetworkError",
"ObservabilityEventType",
"ObservabilityHooks",
"PraisonAIError",
"StructuredFormatter",
"ToolExecutionError",
"ValidationError",
"config",
"configure_structured_logging",
"embedding",
@@ -2868,15 +2882,6 @@
"status": "DONE",
"category": "tools"
},
{
"feature": "BudgetExceededError",
"python": true,
"typescript": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "CodeAgent",
"python": true,
@@ -3012,33 +3017,6 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffCycleError",
"python": true,
"typescript": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffDepthError",
"python": true,
"typescript": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffError",
"python": true,
"typescript": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffInputData",
"python": true,
@@ -3057,15 +3035,6 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "HandoffTimeoutError",
"python": true,
"typescript": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "ImageAgent",
"python": true,
@@ -3309,6 +3278,15 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "parallel_handoffs",
"python": true,
"typescript": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "agent"
},
{
"feature": "prompt_with_handoff_instructions",
"python": true,
@@ -4143,6 +4121,15 @@
"status": "DONE",
"category": "bots"
},
{
"feature": "BudgetExceededError",
"python": true,
"typescript": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "CACHING_PRESETS",
"python": true,
@@ -4314,6 +4301,15 @@
"status": "DONE",
"category": "embedding"
},
{
"feature": "ErrorContextProtocol",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "EscalationPipeline",
"python": true,
@@ -4485,6 +4481,42 @@
"status": "DONE",
"category": "config"
},
{
"feature": "HandoffCycleError",
"python": true,
"typescript": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffDepthError",
"python": true,
"typescript": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffError",
"python": true,
"typescript": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "HandoffTimeoutError",
"python": true,
"typescript": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "Heartbeat",
"python": true,
@@ -4539,6 +4571,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "LLMError",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "LLMPluginProtocol",
"python": true,
@@ -4719,6 +4760,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "NetworkError",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "OUTPUT_PRESETS",
"python": true,
@@ -4854,6 +4904,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "PraisonAIError",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "PraisonConfig",
"python": true,
@@ -5025,6 +5084,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "ToolExecutionError",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "ToolPluginProtocol",
"python": true,
@@ -5052,6 +5120,15 @@
"status": "DONE",
"category": "trace"
},
{
"feature": "ValidationError",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "WEB_PRESETS",
"python": true,
+23 -16
View File
@@ -1,32 +1,32 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-04-01
> **Version:** 1.5.87 | **Last Updated:** 2026-04-08
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 314 |
| Python Core Features | 321 |
| Python Wrapper Features | 99 |
| TypeScript Features | 1192 |
| **Gap Count** | **21** |
| P0 (Critical) | 0 |
| **Gap Count** | **28** |
| P0 (Critical) | 1 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low) | 21 |
| P3 (Low) | 27 |
## Gap Matrix
### P0_CoreParity (57 done, 0 todo)
### P0_CoreParity (52 done, 1 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
| `parallel\_handoffs` | ✅ | ❌ | low | ⏳ TODO |
| `Agent` | ✅ | ✅ | high | ✅ DONE |
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
| `AudioConfig` | ✅ | ✅ | low | ✅ DONE |
| `BaseTool` | ✅ | ✅ | high | ✅ DONE |
| `BudgetExceededError` | ✅ | ✅ | low | ✅ DONE |
| `CodeAgent` | ✅ | ✅ | high | ✅ DONE |
| `CodeConfig` | ✅ | ✅ | low | ✅ DONE |
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ DONE |
@@ -42,12 +42,8 @@
| `FunctionTool` | ✅ | ✅ | high | ✅ DONE |
| `Handoff` | ✅ | ✅ | high | ✅ DONE |
| `HandoffConfig` | ✅ | ✅ | low | ✅ DONE |
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffDepthError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffInputData` | ✅ | ✅ | high | ✅ DONE |
| `HandoffResult` | ✅ | ✅ | low | ✅ DONE |
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
| `ImageAgent` | ✅ | ✅ | high | ✅ DONE |
| `MCPCall` | ✅ | ✅ | high | ✅ DONE |
| `OCRAgent` | ✅ | ✅ | high | ✅ DONE |
@@ -151,7 +147,7 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (175 done, 21 todo)
### P3_Advanced (180 done, 27 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -161,19 +157,25 @@
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
| `EscalationStage` | ✅ | ❌ | high | ⏳ TODO |
| `Heartbeat` | ✅ | ❌ | high | ⏳ TODO |
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ TODO |
| `LLMError` | ✅ | ❌ | low | ⏳ TODO |
| `LearnBackend` | ✅ | ❌ | high | ⏳ TODO |
| `LearnManager` | ✅ | ❌ | high | ⏳ TODO |
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `LearnMode` | ✅ | ❌ | high | ⏳ TODO |
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
| `NetworkError` | ✅ | ❌ | low | ⏳ TODO |
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
| `PraisonAIError` | ✅ | ❌ | low | ⏳ TODO |
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
| `A2A` | ✅ | ✅ | low | ✅ DONE |
@@ -199,6 +201,7 @@
| `BotMessage` | ✅ | ✅ | high | ✅ DONE |
| `BotProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `BotUser` | ✅ | ✅ | high | ✅ DONE |
| `BudgetExceededError` | ✅ | ✅ | low | ✅ DONE |
| `CACHING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `CachingConfig` | ✅ | ✅ | low | ✅ DONE |
@@ -233,6 +236,10 @@
| `GatewaySessionProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `GuardrailAction` | ✅ | ✅ | high | ✅ DONE |
| `GuardrailConfig` | ✅ | ✅ | low | ✅ DONE |
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffDepthError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffError` | ✅ | ✅ | low | ✅ DONE |
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
| `HookPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `HooksConfig` | ✅ | ✅ | low | ✅ DONE |
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
@@ -357,10 +364,10 @@
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents`
<details>
<summary><strong>agent</strong> (46 exports)</summary>
<summary><strong>agent</strong> (42 exports)</summary>
```python
from praisonaiagents import Agent, AudioAgent, AudioConfig, BudgetExceededError, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent...
from praisonaiagents import Agent, AudioAgent, AudioConfig, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent, DeepResearchResponse...
```
</details>
@@ -519,10 +526,10 @@ from praisonaiagents import obs
</details>
<details>
<summary><strong>other</strong> (22 exports)</summary>
<summary><strong>other</strong> (33 exports)</summary>
```python
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, DoomLoopDetector, EscalationPipeline, EscalationStage, Heartbeat, HeartbeatConfig, LearnManager, LearnManagerProtocol...
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage, HandoffCycleError, HandoffDepthError...
```
</details>
+47 -5
View File
@@ -4,6 +4,7 @@
*/
import { AgentFlow, Task, TaskConfig } from './index';
import * as path from 'path';
export interface YAMLWorkflowDefinition {
name: string;
@@ -35,12 +36,19 @@ export interface ParsedWorkflow {
errors: string[];
}
// Whitelist of allowed step keys to prevent injection
const ALLOWED_STEP_KEYS = new Set([
'type', 'agent', 'tool', 'input', 'output', 'condition',
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
]);
/**
* Parse YAML string into workflow definition
*/
export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
// Simple YAML parser for workflow definitions
// For production, use js-yaml package
// SECURITY: If migrating to js-yaml, you MUST use:
// yaml.load(content, { schema: yaml.JSON_SCHEMA })
// Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution.
const lines = yamlContent.split('\n');
const result: YAMLWorkflowDefinition = {
name: '',
@@ -82,7 +90,11 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
type: 'agent'
};
} else if (currentStep) {
// Step properties
// Step properties — whitelist allowed keys to prevent injection
if (!ALLOWED_STEP_KEYS.has(key)) {
// Ignore unknown keys — do not allow arbitrary property injection
continue;
}
if (key === 'type') currentStep.type = value as any;
else if (key === 'agent') currentStep.agent = value;
else if (key === 'tool') currentStep.tool = value;
@@ -265,10 +277,40 @@ function parseValue(value: string): any {
export async function loadWorkflowFromFile(
filePath: string,
agents: Record<string, any> = {},
tools: Record<string, any> = {}
tools: Record<string, any> = {},
options: { basePath?: string; maxFileSizeBytes?: number } = {}
): Promise<ParsedWorkflow> {
const fs = await import('fs/promises');
const content = await fs.readFile(filePath, 'utf-8');
// SECURITY: Prevent path traversal
const normalizedPath = path.normalize(filePath);
// Check for '..' as path segments (not just substring)
const pathSegments = normalizedPath.split(path.sep);
if (pathSegments.includes('..')) {
throw new Error('Path traversal detected: ".." path segments are not allowed');
}
let effectivePath: string;
// If basePath is specified, ensure resolvedPath stays within it
if (options.basePath) {
const resolvedBase = path.resolve(options.basePath);
const resolvedFile = path.resolve(options.basePath, normalizedPath);
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
throw new Error(`File path must be within base directory: ${options.basePath}`);
}
effectivePath = resolvedFile;
} else {
effectivePath = path.resolve(normalizedPath);
}
// SECURITY: Enforce file size limit (default 1 MB)
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
const stat = await fs.stat(effectivePath);
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}
const content = await fs.readFile(effectivePath, 'utf-8');
const definition = parseYAMLWorkflow(content);
return createWorkflowFromYAML(definition, agents, tools);
}
+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.117.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.117.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+98 -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,80 @@ class AgentsGenerator:
else:
agent_planning.pop('planning_tools')
# Extract YAML configuration for advanced features
autonomy_config = details.get('autonomy')
guardrails_config = details.get('guardrails')
# Extract streaming configuration - YAML takes precedence over CLI
has_streaming_config = 'streaming' in details
has_legacy_stream = 'stream' in details
streaming_config = details.get('streaming')
stream_enabled = False
stream_metrics = False
if has_streaming_config:
if isinstance(streaming_config, bool):
stream_enabled = streaming_config
elif isinstance(streaming_config, dict):
stream_enabled = streaming_config.get('enabled', False)
stream_metrics = streaming_config.get('emit_metrics', False)
# Future: can add callbacks, etc. from streaming_config
elif has_legacy_stream: # Also support direct 'stream: true' format
stream_enabled = details.get('stream', False)
# CLI streaming flags override if YAML doesn't specify
cli_config = getattr(self, 'cli_config', {}) or {}
if not has_streaming_config and not has_legacy_stream:
stream_enabled = cli_config.get('stream', False)
stream_metrics = cli_config.get('stream_metrics', False)
# Reconstruct approval config from potentially scattered settings
approval_val = details.get('approval')
approve_all = details.get('approve_all_tools')
approval_timeout = details.get('approval_timeout')
approval_config = None
if approval_val is not None or approve_all is not None or approval_timeout is not None:
if isinstance(approval_val, dict):
approval_dict = approval_val
else:
approval_dict = {'backend': approval_val}
if approve_all is not None:
approval_dict['approve_all_tools'] = approve_all
if approval_timeout is not None:
approval_dict['approval_timeout'] = approval_timeout
try:
from .cli.features.approval import resolve_approval_config
# Map common YAML fields to resolve_approval_config parameters
approval_config = resolve_approval_config(
backend_name=approval_dict.get('backend') or approval_dict.get('backend_name'),
all_tools=approval_dict.get('approve_all_tools') or approval_dict.get('all_tools', False),
timeout=approval_dict.get('approval_timeout') or approval_dict.get('timeout')
)
except ImportError:
# Fallback: Create ApprovalConfig directly if resolve_approval_config isn't available
try:
from praisonaiagents.approval.protocols import ApprovalConfig
approval_config = ApprovalConfig(
backend=approval_dict.get('backend', None),
all_tools=approval_dict.get('approve_all_tools', approval_dict.get('all_tools', False)),
timeout=approval_dict.get('approval_timeout', approval_dict.get('timeout', 0))
)
except ImportError:
# Last resort: disable approval for this agent
approval_config = None
# Build output configuration with streaming support
output_config = None
if stream_enabled:
try:
from praisonaiagents.config import OutputConfig
output_config = OutputConfig(stream=True, metrics=stream_metrics)
except ImportError:
self.logger.warning("OutputConfig not available, streaming disabled")
agent = PraisonAgent(
name=role_filled,
role=role_filled,
@@ -1191,6 +1282,10 @@ class AgentsGenerator:
reflection=details.get('reflection', False),
tool_timeout=agent_tool_timeout,
planning=agent_planning,
autonomy=autonomy_config,
guardrails=guardrails_config,
approval=approval_config,
output=output_config,
)
if self.agent_callback:
+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}"}
@@ -8,16 +8,84 @@ making the Agent the central orchestrator for all actions.
import asyncio
import json
import logging
from typing import Callable, Dict, List, TYPE_CHECKING
from typing import Callable, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .interactive_runtime import InteractiveRuntime
from .code_intelligence import CodeIntelligenceRouter
from .action_orchestrator import ActionOrchestrator
import os
from pathlib import Path
logger = logging.getLogger(__name__)
def _sanitize_filepath(filepath: str, workspace: Optional[str] = None) -> str:
"""Validate and sanitize a filepath against injection attacks.
Raises ValueError if the path is unsafe.
"""
# Reject null bytes (common injection technique)
if '\x00' in filepath:
raise ValueError("Null bytes are not allowed in file paths")
# Reject obvious shell meta-characters in file names
if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')):
raise ValueError(f"Unsafe characters in filepath: {filepath!r}")
# Reject absolute paths — files must be relative to workspace
if os.path.isabs(filepath):
raise ValueError(f"Absolute paths are not allowed: {filepath!r}")
# Normalize and reject path traversal
normalized = os.path.normpath(filepath)
if '..' in normalized.split(os.sep):
raise ValueError(f"Path traversal detected in: {filepath!r}")
# If we have a workspace, ensure the resolved path stays within it
if workspace:
resolved = Path(os.path.realpath(os.path.join(workspace, normalized)))
ws_resolved = Path(os.path.realpath(workspace))
try:
resolved.relative_to(ws_resolved)
except ValueError:
raise ValueError(
f"Path {filepath!r} resolves outside workspace {workspace!r}"
)
return normalized
def _sanitize_command(command: str) -> str:
"""Basic command sanitization — reject common injection vectors.
The command still goes through ACP approval flow, but this prevents
the most egregious prompt-injection-to-shell-injection chains.
Raises ValueError if dangerous patterns are detected.
"""
if '\x00' in command:
raise ValueError("Null bytes are not allowed in commands")
# Reject command chaining operators that indicate injection
DANGEROUS_PATTERNS = [
'$(', '`', # Command substitution
'&&', '||', # Command chaining
'>>', '>', # Output redirection
'|', ';', '&', # Pipe and separators
'\n', '\r' # Line breaks
]
for pattern in DANGEROUS_PATTERNS:
if pattern in command:
raise ValueError(
f"Potentially unsafe command pattern detected: {pattern!r} "
f"in command: {command!r}. Use separate commands instead."
)
return command
def create_agent_centric_tools(
runtime: "InteractiveRuntime",
router: "CodeIntelligenceRouter" = None,
@@ -77,8 +145,17 @@ def create_agent_centric_tools(
JSON string with result including plan status and verification
"""
async def _create():
# Validate filepath
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
# Build a detailed prompt for the orchestrator
prompt = f"create file {filepath}"
prompt = f"create file {safe_path}"
# Create plan
result = await orchestrator.create_plan(prompt)
@@ -141,7 +218,15 @@ def create_agent_centric_tools(
JSON string with result including plan status
"""
async def _edit():
prompt = f"edit file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"edit file {safe_path}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -189,7 +274,15 @@ def create_agent_centric_tools(
JSON string with result
"""
async def _delete():
prompt = f"delete file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"delete file {safe_path}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -234,7 +327,12 @@ def create_agent_centric_tools(
JSON string with command output
"""
async def _execute():
prompt = f"run command: {command}"
try:
safe_cmd = _sanitize_command(command)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})
prompt = f"run command: {safe_cmd}"
result = await orchestrator.create_plan(prompt)
if not result.success:
@@ -414,10 +512,18 @@ def create_agent_centric_tools(
if not path.is_absolute():
path = Path(runtime.config.workspace) / filepath
if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
try:
resolved.relative_to(ws_resolved)
except ValueError:
return json.dumps({"error": f"Path escapes workspace: {filepath}"})
if not resolved.exists():
return json.dumps({"error": f"File not found: {filepath}"})
content = path.read_text()
content = resolved.read_text()
# Track in trace if enabled
if runtime._trace:
@@ -451,7 +557,15 @@ def create_agent_centric_tools(
if not path.is_absolute():
path = Path(runtime.config.workspace) / directory
if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
try:
resolved.relative_to(ws_resolved)
except ValueError:
return json.dumps({"error": f"Directory escapes workspace: {directory}"})
if not resolved.exists():
return json.dumps({"error": f"Directory not found: {directory}"})
files = []
@@ -30,15 +30,24 @@ def resolve_approval_backend(value: Optional[str]) -> Optional[Any]:
Raises:
ValueError: If the value is not a recognised backend name.
"""
if value is None or value.lower() == "none":
if value is None:
return None
if isinstance(value, bool):
value = "true" if value else "false"
if str(value).lower() == "none":
return None
name = value.lower().strip()
name = str(value).lower().strip()
if name in ("true", "console"):
if name in ("true", "console", "yes", "1"):
from praisonaiagents.approval.backends import ConsoleBackend
return ConsoleBackend()
if name in ("false", "no", "0"):
return None
if name == "auto":
from praisonaiagents.approval.backends import AutoApproveBackend
return AutoApproveBackend()
@@ -847,8 +847,14 @@ class TemplatesHandler:
zip_path = Path(tmpdir) / "repo.zip"
urllib.request.urlretrieve(zip_url, zip_path)
# Extract
# Extract safely, preventing Zip Slip
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
import os
tmpdir_real = os.path.realpath(tmpdir)
for zip_info in zip_ref.infolist():
extracted_path = os.path.realpath(os.path.join(tmpdir_real, zip_info.filename))
if not extracted_path.startswith(tmpdir_real):
raise Exception(f"Attempted Zip Slip vulnerabilities detected: {zip_info.filename}")
zip_ref.extractall(tmpdir)
# Find the template
+31
View File
@@ -928,6 +928,8 @@ class PraisonAI:
parser.add_argument("--web", "--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
parser.add_argument("--stream", action="store_true", help="Enable real-time streaming for agent responses")
parser.add_argument("--stream-metrics", action="store_true", help="Enable streaming with token metrics display")
# Planning Mode arguments
parser.add_argument("--planning", action="store_true", help="Enable planning mode - create plan before execution")
@@ -3996,6 +3998,35 @@ Do NOT add any explanations or formatting."""
if getattr(self.args, 'lsp', False):
cli_config['lsp'] = True
# Extract new agent-level CLI flags for YAML parity
autonomy = getattr(self.args, 'autonomy', None)
if autonomy is not None:
cli_config['autonomy'] = autonomy
guardrail = getattr(self.args, 'guardrail', None)
if guardrail is not None:
cli_config['guardrail'] = guardrail
approval = getattr(self.args, 'approval', None)
if approval is not None:
cli_config['approval'] = approval
approve_all_tools = getattr(self.args, 'approve_all_tools', None)
if approve_all_tools is not None:
cli_config['approve_all_tools'] = approve_all_tools
approval_timeout = getattr(self.args, 'approval_timeout', None)
if approval_timeout is not None:
cli_config['approval_timeout'] = approval_timeout
# Extract streaming configuration for YAML CLI parity
stream = getattr(self.args, 'stream', False)
stream_metrics = getattr(self.args, 'stream_metrics', False)
if stream or stream_metrics:
cli_config['stream'] = stream or stream_metrics
if stream_metrics:
cli_config['stream_metrics'] = True
return cli_config
def handle_direct_prompt(self, prompt):
+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.117 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -7,6 +7,7 @@ Provides SSE-based event streaming for agent-to-user communication.
import asyncio
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -240,8 +241,36 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
except ImportError:
raise ImportError("Starlette or FastAPI required for A2U routes")
def _authenticate_request(request) -> Optional[JSONResponse]:
"""Check bearer token auth when A2U_AUTH_TOKEN is configured.
Returns None if authenticated, or a 401/403 JSONResponse otherwise.
"""
auth_token = os.environ.get("A2U_AUTH_TOKEN")
if not auth_token:
# No token configured — auth disabled (development mode)
return None
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
{"error": "Authentication required. Set Authorization: Bearer <token>"},
status_code=401,
)
import hmac
provided = auth_header[7:] # strip "Bearer "
if not hmac.compare_digest(provided, auth_token):
return JSONResponse(
{"error": "Invalid authentication token"},
status_code=403,
)
async def a2u_info(request):
"""GET /a2u/info - Get A2U server info."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
return JSONResponse({
"name": "A2U Event Stream",
"version": "1.0.0",
@@ -258,6 +287,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_subscribe(request):
"""POST /a2u/subscribe - Subscribe to an event stream."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
try:
body = await request.json()
except Exception:
@@ -279,6 +311,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_unsubscribe(request):
"""POST /a2u/unsubscribe - Unsubscribe from an event stream."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
try:
body = await request.json()
except Exception:
@@ -295,6 +330,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_events_stream(request):
"""GET /a2u/events/{stream_name} - Stream events via SSE."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
stream_name = request.path_params.get("stream_name", "events")
# Create subscription for this stream
@@ -319,6 +357,9 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None
async def a2u_events_subscription(request):
"""GET /a2u/events/sub/{subscription_id} - Stream events for subscription."""
auth_error = _authenticate_request(request)
if auth_error:
return auth_error
subscription_id = request.path_params.get("subscription_id")
if subscription_id not in bus._subscriptions:
@@ -371,9 +371,11 @@ class OAuthManager:
Returns:
True if valid
"""
import hmac
# Check if we have this token stored
for stored_token in self._tokens.values():
if stored_token.access_token == token:
# Use hmac.compare_digest to prevent timing attacks
if hmac.compare_digest(stored_token.access_token, token):
return not stored_token.is_expired()
# Token not found — deny by default
@@ -0,0 +1,24 @@
"""
PraisonAI Observability Integrations.
Observability adapters for external platforms like Langfuse, WandB, etc.
These live in the wrapper layer and implement TraceSinkProtocol from the core SDK.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .langfuse import LangfuseSink, LangfuseSinkConfig
__all__ = []
def __getattr__(name: str):
"""Lazy imports for optional observability dependencies."""
if name == "LangfuseSink":
from .langfuse import LangfuseSink
return LangfuseSink
elif name == "LangfuseSinkConfig":
from .langfuse import LangfuseSinkConfig
return LangfuseSinkConfig
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,294 @@
"""
Langfuse TraceSinkProtocol Implementation for PraisonAI.
Provides LangfuseSink adapter that implements TraceSinkProtocol from the core SDK,
enabling observability integration with Langfuse for agent traces, tool calls, and errors.
Architecture:
- Core SDK (praisonaiagents): Defines TraceSinkProtocol
- Wrapper (praisonai): Implements LangfuseSink adapter (this file)
- Pattern: Protocol-driven design per AGENTS.md §4.1
"""
import json
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol
@dataclass
class LangfuseSinkConfig:
"""
Configuration for Langfuse trace sink.
Follows the False=disabled, True=defaults, Config=custom pattern from AGENTS.md §5.2.
Fields not explicitly provided are filled from environment variables:
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST (or LANGFUSE_BASE_URL).
"""
public_key: str = ""
secret_key: str = ""
host: str = ""
flush_at: int = 20
flush_interval: float = 10.0
enabled: bool = True
def __post_init__(self):
"""Fill missing fields from environment variables."""
import os
if not self.public_key:
self.public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "")
if not self.secret_key:
self.secret_key = os.getenv("LANGFUSE_SECRET_KEY", "")
if not self.host:
self.host = os.getenv(
"LANGFUSE_HOST",
os.getenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com"),
)
class LangfuseSink:
"""
TraceSinkProtocol implementation for Langfuse.
Maps PraisonAI ActionEvents to Langfuse v3+ observation API:
- AGENT_START -> start_observation(as_type="span") for trace and root span
- AGENT_END -> observation.update() + observation.end()
- TOOL_START -> start_observation(as_type="span") for child span
- TOOL_END -> observation.update() + observation.end()
- ERROR -> start_observation(as_type="event", level="ERROR")
- OUTPUT -> start_observation(as_type="event")
Thread-safe: langfuse.Langfuse handles its own batching and thread safety.
"""
__slots__ = ("_config", "_client", "_traces", "_spans", "_lock", "_closed")
def __init__(self, config: Optional[LangfuseSinkConfig] = None):
self._config = config or LangfuseSinkConfig()
self._client: Optional[Any] = None # Lazy-loaded langfuse.Langfuse
self._traces: Dict[str, Any] = {} # agent_name -> trace observation
self._spans: Dict[str, Any] = {} # span_key -> span observation
self._lock = threading.Lock()
self._closed = False
if self._config.enabled:
self._initialize_client()
def _initialize_client(self) -> None:
"""Lazy import and initialize Langfuse client."""
try:
import langfuse
# Validate config
if not (self._config.public_key and self._config.secret_key):
raise ValueError(
"Langfuse credentials missing. Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY "
"environment variables or pass them to LangfuseSinkConfig."
)
self._client = langfuse.Langfuse(
public_key=self._config.public_key,
secret_key=self._config.secret_key,
host=self._config.host,
flush_at=self._config.flush_at,
flush_interval=self._config.flush_interval,
)
except ImportError:
raise ImportError(
"Langfuse is not installed. Install with: pip install praisonai[langfuse]"
)
def emit(self, event: ActionEvent) -> None:
"""Emit an action event to Langfuse."""
if not self._config.enabled or self._closed or not self._client:
return
try:
with self._lock:
self._handle_event(event)
except Exception as e:
# Don't let observability errors break agent execution
print(f"LangfuseSink error: {e}")
def _handle_event(self, event: ActionEvent) -> None:
"""Handle a single ActionEvent (called under lock)."""
event_type = event.event_type
agent_name = event.agent_name or "unknown-agent"
if event_type == ActionEventType.AGENT_START.value:
self._handle_agent_start(event, agent_name)
elif event_type == ActionEventType.AGENT_END.value:
self._handle_agent_end(event, agent_name)
elif event_type == ActionEventType.TOOL_START.value:
self._handle_tool_start(event, agent_name)
elif event_type == ActionEventType.TOOL_END.value:
self._handle_tool_end(event, agent_name)
elif event_type == ActionEventType.ERROR.value:
self._handle_error(event, agent_name)
elif event_type == ActionEventType.OUTPUT.value:
self._handle_output(event, agent_name)
def _handle_agent_start(self, event: ActionEvent, agent_name: str) -> None:
"""Handle AGENT_START -> create trace observation and root span."""
# Create root span observation (trace is implicit in v3+)
trace_name = f"agent-run-{agent_name}"
trace_input = event.metadata.get("input") if event.metadata else None
# Use unique agent key combining agent_id and name for collision safety
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
span = self._client.start_observation(
name=trace_name,
as_type="span",
input=trace_input,
start_time=event.timestamp,
metadata={
"agent_id": event.agent_id,
"agent_name": agent_name,
"schema_version": event.schema_version,
**(event.metadata if event.metadata else {}),
}
)
# Store both trace and span reference with unique key
self._traces[agent_key] = span # Root span serves as trace reference
self._spans[agent_key] = span
def _handle_agent_end(self, event: ActionEvent, agent_name: str) -> None:
"""Handle AGENT_END -> end root span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
span = self._spans.get(agent_key)
if span:
span.update(
end_time=event.timestamp,
output=event.metadata.get("output") if event.metadata else None,
status_message=event.status or "completed",
level="ERROR" if event.status == "error" else "DEFAULT",
)
span.end()
# Clean up
self._spans.pop(agent_key, None)
self._traces.pop(agent_key, None)
def _handle_tool_start(self, event: ActionEvent, agent_name: str) -> None:
"""Handle TOOL_START -> create child span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
parent_span = self._spans.get(agent_key)
if not parent_span:
return
tool_name = event.tool_name or "unknown-tool"
# Generate unique tool invocation key with UUID for collision safety
import uuid
tool_invocation_id = str(uuid.uuid4())[:8] # Short UUID
tool_key = f"{agent_key}:{tool_name}:{tool_invocation_id}"
tool_span = self._client.start_observation(
name=tool_name,
as_type="span",
start_time=event.timestamp,
input=event.tool_args,
metadata={
"tool_name": tool_name,
"parent_agent": agent_name,
"invocation_id": tool_invocation_id,
**(event.metadata if event.metadata else {}),
}
)
# Store with unique tool key
self._spans[tool_key] = tool_span
def _handle_tool_end(self, event: ActionEvent, agent_name: str) -> None:
"""Handle TOOL_END -> end tool span observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
tool_name = event.tool_name or "unknown-tool"
# Find the most recent matching tool span
tool_key = None
for key in self._spans:
if key.startswith(f"{agent_key}:{tool_name}:") and key != agent_key:
tool_key = key
if not tool_key:
return
tool_span = self._spans.pop(tool_key, None)
if tool_span:
tool_span.update(
end_time=event.timestamp,
output=event.tool_result_summary,
status_message=event.status or "completed",
level="ERROR" if event.status == "error" else "DEFAULT",
metadata={
"duration_ms": event.duration_ms,
**(event.metadata if event.metadata else {}),
}
)
tool_span.end()
def _handle_error(self, event: ActionEvent, agent_name: str) -> None:
"""Handle ERROR -> create error event observation."""
agent_key = f"{event.agent_id or agent_name}-{agent_name}"
error_event = self._client.start_observation(
name="error",
as_type="event",
start_time=event.timestamp,
level="ERROR",
status_message=event.error_message,
input=event.tool_args,
metadata={
"tool_name": event.tool_name,
"agent_name": agent_name,
**(event.metadata if event.metadata else {}),
}
)
error_event.end()
def _handle_output(self, event: ActionEvent, agent_name: str) -> None:
"""Handle OUTPUT -> create output event observation."""
output_event = self._client.start_observation(
name="output",
as_type="event",
start_time=event.timestamp,
output=event.tool_result_summary,
metadata={
"agent_name": agent_name,
**(event.metadata or {}),
}
)
output_event.end()
def flush(self) -> None:
"""Flush any buffered events."""
if self._client and not self._closed:
try:
self._client.flush()
except Exception as e:
print(f"LangfuseSink flush error: {e}")
def close(self) -> None:
"""Close the sink and release resources."""
if not self._closed:
# Flush before marking closed, so flush() guard passes
self.flush()
self._closed = True
if self._client:
try:
# Close any remaining spans
with self._lock:
for span in self._spans.values():
try:
span.end()
except Exception:
pass
self._spans.clear()
self._traces.clear()
except Exception:
pass
@@ -8,6 +8,7 @@ Install: pip install asyncpg
import asyncio
import json
import logging
import threading
import time
from typing import List, Optional
@@ -63,35 +64,43 @@ class AsyncPostgresConversationStore(ConversationStore):
self.pool_size = pool_size
self._pool = None
self._initialized = False
self._init_lock = None
self._lock_creation_lock = threading.Lock()
async def init(self):
"""Initialize connection pool and create tables."""
if self._initialized:
return
if self._init_lock is None:
with self._lock_creation_lock:
if self._init_lock is None: # Double-checked locking
self._init_lock = asyncio.Lock()
try:
import asyncpg
except ImportError:
raise ImportError(
"asyncpg is required for async PostgreSQL support. "
"Install with: pip install asyncpg"
)
if self.url:
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
else:
self._pool = await asyncpg.create_pool(
host=self.host,
port=self.port,
database=self.database,
user=self.user,
password=self.password,
min_size=1,
max_size=self.pool_size
)
await self._create_tables()
self._initialized = True
async with self._init_lock:
if self._initialized:
return
try:
import asyncpg
except ImportError as err:
raise ImportError(
"asyncpg is required for async PostgreSQL support. "
"Install with: pip install asyncpg"
) from err
if self.url:
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
else:
self._pool = await asyncpg.create_pool(
host=self.host,
port=self.port,
database=self.database,
user=self.user,
password=self.password,
min_size=1,
max_size=self.pool_size
)
await self._create_tables()
self._initialized = True
async def _create_tables(self):
"""Create required tables if they don't exist."""
@@ -97,8 +97,9 @@ class MemoryStateStore(StateStore):
def _maybe_save(self) -> None:
"""Save if auto_save is enabled and interval has passed."""
if self.auto_save and self.path:
if time.time() - self._last_save >= self.save_interval:
self._save()
with self._lock:
if time.time() - self._last_save >= self.save_interval:
self._save()
def _check_ttl(self, key: str) -> bool:
"""Check if key is expired. Returns True if valid, False if expired."""
+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.117"
+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.117",
"python-dotenv>=0.19.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
@@ -44,6 +44,7 @@ os = [
"sse-starlette>=1.6.0"
]
agentops = ["agentops>=0.3.12"]
langfuse = ["langfuse>=3.0.0"]
chat = [
"chainlit>=2.8.5,<=2.9.4",
"aiosqlite>=0.20.0",
@@ -125,6 +126,8 @@ all = [
"yfinance>=0.2.44",
"ddgs>=9.0.0",
"psutil>=5.9.0", # Shell tools
# Observability
"langfuse>=3.0.0",
# Storage backends
"redis>=5.0.0",
"pymongo>=4.6.0",
@@ -183,6 +186,8 @@ claw = [
"ddgs>=9.0.0",
# Shell tools
"psutil>=5.9.0",
# Observability
"langfuse>=3.0.0",
# Bot channels (Telegram, Discord, Slack)
"python-telegram-bot>=20.0",
"discord.py>=2.0.0",
+9 -5
View File
@@ -67,16 +67,20 @@ def calculate(expression: str) -> str:
'''
# Write the test files
with open("test_agents.yaml", "w") as f:
import os
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
os.makedirs(FIXTURES_DIR, exist_ok=True)
with open(os.path.join(FIXTURES_DIR, "test_agents.yaml"), "w") as f:
f.write(agents_yaml_content)
with open("test_tools.py", "w") as f:
with open(os.path.join(FIXTURES_DIR, "test_tools.py"), "w") as f:
f.write(tools_py_content)
print("Test files created successfully!")
print("Test files created successfully in tests/fixtures/!")
print("\nTo test the fix:")
print("1. Copy test_agents.yaml to agents.yaml")
print("2. Copy test_tools.py to tools.py")
print("1. Copy tests/fixtures/test_agents.yaml to agents.yaml")
print("2. Copy tests/fixtures/test_tools.py to tools.py")
print("3. Run the chatbot UI")
print("4. Ask the agent to use multiple tools")
print("\nThe agent should now be able to use all three tools (search_web, get_weather, calculate)")
+2 -2
View File
@@ -121,9 +121,9 @@ class TestApiMdGenerator:
agent_info = generator.agents_symbols["Agent"]
assert len(agent_info.methods) > 0, "Agent class should have methods"
# Check for common methods
# Check for common methods that are directly in Agent class (not in mixins)
method_names = [m.name for m in agent_info.methods]
assert "start" in method_names or "run" in method_names or "chat" in method_names
assert "__init__" in method_names or "agent_id" in method_names or "analyze_prompt" in method_names
class TestTypescriptDiscovery:
@@ -121,9 +121,12 @@ class TestEnhancedYAMLGeneration:
from praisonai.auto import AutoGenerator
# Test that the generator has tool discovery capability
import os
fixture_path = os.path.join(os.path.dirname(__file__), "..", "fixtures", "test_agents.yaml")
generator = AutoGenerator(
topic="Search the web for AI news and write a summary",
agent_file="test_agents.yaml",
agent_file=fixture_path,
framework="praisonai"
)
@@ -0,0 +1,308 @@
"""
Unit tests for LangfuseSink observability adapter.
Tests verify:
1. LangfuseSinkConfig picks up env vars correctly
2. LangfuseSink raises ValueError (not ImportError) for missing credentials
3. LangfuseSink silently handles events when disabled
4. LangfuseSink correctly routes events to Langfuse API (via mocks)
5. flush() / close() lifecycle is handled correctly
6. Sink does not let errors propagate to calling code
7. Protocol compliance with TraceSinkProtocol
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol
from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_event(event_type, agent_name="agent1", **kwargs):
"""Create an ActionEvent for testing."""
return ActionEvent(
event_type=event_type,
timestamp=time.time(),
agent_name=agent_name,
**kwargs,
)
def _make_sink_with_mock_client():
"""Return a sink with a mock Langfuse client already injected."""
cfg = LangfuseSinkConfig(enabled=False)
sink = LangfuseSink(cfg)
sink._client = MagicMock()
sink._config.enabled = True
return sink
# ---------------------------------------------------------------------------
# LangfuseSinkConfig tests
# ---------------------------------------------------------------------------
class TestLangfuseSinkConfig:
def test_defaults_when_env_empty(self, monkeypatch):
"""Config falls back to the default cloud host when env vars not set."""
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
monkeypatch.delenv("LANGFUSE_HOST", raising=False)
monkeypatch.delenv("LANGFUSE_BASE_URL", raising=False)
cfg = LangfuseSinkConfig(enabled=False)
assert cfg.public_key == ""
assert cfg.secret_key == ""
assert cfg.host == "https://cloud.langfuse.com"
def test_reads_env_vars(self, monkeypatch):
"""Config reads credentials from env vars."""
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
monkeypatch.setenv("LANGFUSE_HOST", "http://localhost:3000")
cfg = LangfuseSinkConfig()
assert cfg.public_key == "pk-test"
assert cfg.secret_key == "sk-test"
assert cfg.host == "http://localhost:3000"
def test_explicit_values_take_precedence(self, monkeypatch):
"""Explicitly provided values override env vars."""
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-env")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-env")
cfg = LangfuseSinkConfig(public_key="pk-explicit", secret_key="sk-explicit")
assert cfg.public_key == "pk-explicit"
assert cfg.secret_key == "sk-explicit"
def test_enabled_false_with_no_credentials(self, monkeypatch):
"""Disabled config doesn't need credentials."""
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
cfg = LangfuseSinkConfig(enabled=False)
assert cfg.enabled is False
# ---------------------------------------------------------------------------
# LangfuseSink initialization tests
# ---------------------------------------------------------------------------
class TestLangfuseSinkInit:
def test_disabled_sink_does_not_initialize_client(self):
"""Disabled sink should not attempt to import langfuse."""
cfg = LangfuseSinkConfig(enabled=False)
sink = LangfuseSink(cfg)
assert sink._client is None
assert sink._closed is False
def test_missing_credentials_raises_value_error(self, monkeypatch):
"""ValueError (not ImportError) raised when credentials missing."""
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
cfg = LangfuseSinkConfig(public_key="", secret_key="", enabled=True)
with patch.dict("sys.modules", {"langfuse": MagicMock()}):
with pytest.raises(ValueError, match="credentials missing"):
LangfuseSink(cfg)
def test_initialized_with_valid_credentials(self, monkeypatch):
"""Sink initializes successfully with valid credentials."""
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
mock_langfuse_module = MagicMock()
mock_client = MagicMock()
mock_langfuse_module.Langfuse.return_value = mock_client
cfg = LangfuseSinkConfig()
with patch.dict("sys.modules", {"langfuse": mock_langfuse_module}):
sink = LangfuseSink(cfg)
assert sink._client is mock_client
# ---------------------------------------------------------------------------
# LangfuseSink event emission tests
# ---------------------------------------------------------------------------
class TestLangfuseSinkEmit:
def test_emit_agent_start_creates_trace_and_span(self):
"""AGENT_START event creates a trace and root span."""
sink = _make_sink_with_mock_client()
mock_span = MagicMock()
sink._client.start_observation.return_value = mock_span
event = _make_event(ActionEventType.AGENT_START.value)
sink.emit(event)
sink._client.start_observation.assert_called_once()
call_kwargs = sink._client.start_observation.call_args.kwargs
assert call_kwargs.get("as_type") == "span"
assert "agent1-agent1" in sink._traces
assert "agent1-agent1" in sink._spans
def test_emit_agent_end_ends_span(self):
"""AGENT_END event ends the root span."""
sink = _make_sink_with_mock_client()
mock_span = MagicMock()
sink._spans["agent1-agent1"] = mock_span
sink._traces["agent1-agent1"] = MagicMock()
event = _make_event(ActionEventType.AGENT_END.value, status="ok")
sink.emit(event)
mock_span.end.assert_called_once()
assert "agent1-agent1" not in sink._spans
assert "agent1-agent1" not in sink._traces
def test_emit_tool_start_creates_child_span(self):
"""TOOL_START creates a child span under the parent agent span."""
sink = _make_sink_with_mock_client()
mock_parent_span = MagicMock()
mock_tool_span = MagicMock()
sink._client.start_observation.return_value = mock_tool_span
sink._spans["agent1-agent1"] = mock_parent_span
event = _make_event(
ActionEventType.TOOL_START.value,
tool_name="search_tool",
tool_args={"query": "test"},
)
sink.emit(event)
sink._client.start_observation.assert_called_once()
call_kwargs = sink._client.start_observation.call_args.kwargs
assert call_kwargs.get("name") == "search_tool"
tool_keys = [k for k in sink._spans if k.startswith("agent1-agent1:search_tool:")]
assert len(tool_keys) == 1
def test_emit_tool_end_closes_tool_span(self):
"""TOOL_END ends the matching tool span."""
sink = _make_sink_with_mock_client()
mock_tool_span = MagicMock()
ts = str(time.time())
sink._spans[f"agent1-agent1:search_tool:{ts}"] = mock_tool_span
event = _make_event(
ActionEventType.TOOL_END.value,
tool_name="search_tool",
duration_ms=100.0,
status="ok",
tool_result_summary="result",
)
sink.emit(event)
mock_tool_span.end.assert_called_once()
def test_emit_error_creates_error_event(self):
"""ERROR event creates a Langfuse event with level=ERROR."""
sink = _make_sink_with_mock_client()
event = _make_event(
ActionEventType.ERROR.value,
error_message="Something went wrong",
)
sink.emit(event)
sink._client.start_observation.assert_called_once()
call_kwargs = sink._client.start_observation.call_args.kwargs
assert call_kwargs.get("as_type") == "event"
assert call_kwargs.get("level") == "ERROR"
def test_emit_output_creates_output_event(self):
"""OUTPUT event creates a Langfuse event."""
sink = _make_sink_with_mock_client()
event = _make_event(
ActionEventType.OUTPUT.value,
tool_result_summary="Final answer",
)
sink.emit(event)
sink._client.start_observation.assert_called_once()
call_kwargs = sink._client.start_observation.call_args.kwargs
assert call_kwargs.get("as_type") == "event"
assert call_kwargs.get("name") == "output"
def test_emit_when_disabled_is_noop(self):
"""emit() does nothing when sink is disabled."""
cfg = LangfuseSinkConfig(enabled=False)
sink = LangfuseSink(cfg)
mock_client = MagicMock()
sink._client = mock_client
event = _make_event(ActionEventType.AGENT_START.value)
sink.emit(event)
mock_client.trace.assert_not_called()
def test_emit_when_closed_is_noop(self):
"""emit() does nothing after close()."""
sink = _make_sink_with_mock_client()
sink._closed = True
event = _make_event(ActionEventType.AGENT_START.value)
sink.emit(event)
sink._client.trace.assert_not_called()
def test_emit_exception_does_not_propagate(self):
"""Exceptions inside emit() are caught and do not bubble up."""
sink = _make_sink_with_mock_client()
sink._client.trace.side_effect = RuntimeError("Langfuse network error")
event = _make_event(ActionEventType.AGENT_START.value)
sink.emit(event) # Must NOT raise
# ---------------------------------------------------------------------------
# Lifecycle tests
# ---------------------------------------------------------------------------
class TestLangfuseSinkLifecycle:
def test_flush_calls_client_flush(self):
sink = _make_sink_with_mock_client()
sink.flush()
sink._client.flush.assert_called_once()
def test_flush_after_close_is_noop(self):
sink = _make_sink_with_mock_client()
sink.close()
sink._client.flush.reset_mock()
sink.flush()
sink._client.flush.assert_not_called()
def test_close_flushes_and_closes_remaining_spans(self):
sink = _make_sink_with_mock_client()
mock_span = MagicMock()
sink._spans["agent1-agent1"] = mock_span
sink.close()
sink._client.flush.assert_called_once()
mock_span.end.assert_called_once()
assert sink._closed is True
assert not sink._spans
def test_double_close_is_safe(self):
sink = _make_sink_with_mock_client()
sink.close()
sink.close() # Should not raise
# ---------------------------------------------------------------------------
# Protocol compliance
# ---------------------------------------------------------------------------
class TestLangfuseSinkProtocol:
def test_implements_trace_sink_protocol(self):
"""LangfuseSink satisfies TraceSinkProtocol at runtime."""
sink = LangfuseSink(LangfuseSinkConfig(enabled=False))
assert isinstance(sink, TraceSinkProtocol)
+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.117"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },