Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 3e9826bda7 fix: resolve missing AgentTeam import and RouterAgent kwargs forwarding
- Fix missing AgentTeam import in self-reflection-optimization-patterns.py
- Forward kwargs properly in RouterAgent._build_system_prompt to super()
- Addresses critical issues identified by Gemini code reviewer

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
praisonai-triage-agent[bot]andMervin Praison fc298049e2 fix: resolve critical RouterAgent **kwargs bug and add missing if __name__ guards
- Fixed TypeError in RouterAgent._build_system_prompt() by removing **kwargs from super() call
- Added if __name__ == '__main__' guards to 6 example files to prevent auto-execution on import
- Addresses Gemini Code Assist review feedback

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
Dhivya-Bharathy 0b7bf10820 fix(examples): phase-4 API alignment for 40 validation failures on v4.6.52 2026-06-12 23:25:32 +00:00
Dhivya-Bharathy cbb7c726aa fix(examples): phase-3 API alignment for consolidated params on v4.6.52 2026-06-12 23:25:12 +00:00
Dhivya-Bharathy 21d1986d0c fix(examples): align AgentTeam memory and knowledge configs with v4.6.52 API 2026-06-12 23:25:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 26b5792192 fix: correct TypeError in 3 example files by using proper parameter names
- Fix MemoryConfig(memory=...) → user_id=... in agent_centric_api.py
- Fix Session(memory=...) → user_id=... in session-example.py
- Fix build_context_for_task(memory=...) → user_id=... in memory-quality-example.py

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:12 +00:00
Dhivya-Bharathy afcea5800e fix(examples): resolve syntax errors and align with consolidated-params API 2026-06-12 23:25:12 +00:00
98cfdcc9ad fix: resolve three architectural gaps in wrapper layer (#1867)
* fix: resolve three architectural gaps in wrapper layer (fixes #1866)

Gap 1: resolve_llm_endpoint() now maps model prefixes to correct provider API keys
- Added provider mapping for anthropic/, groq/, google/, openrouter/, etc.
- Stops hardcoding OPENAI_API_KEY for all providers
- Maintains fallback to OPENAI_API_KEY for default OpenAI models

Gap 2: SubprocessSandbox now enforces SecurityPolicy and ResourceLimits
- Builds child environment from policy instead of copying host environment
- Applies resource limits via POSIX setrlimit where supported
- Enforces max_output_size to prevent OOM attacks
- Uses process groups for proper timeout cleanup
- Provides warnings when isolation is limited (Windows, missing modules)

Gap 3: Remove root logger mutation from inc/models.py
- Removed logging.basicConfig() call that violated project logging policy
- CLI logging configuration remains through _logging.py as intended
- Embedders no longer have their root logger hijacked at import time

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

* fix: resolve critical bugs in architectural gaps implementation

- Fix inverted boolean condition in llm/env.py causing wrong API key fallback
- Add proper provider base URLs to prevent misrouting to OpenAI endpoints
- Add Windows compatibility for subprocess sandbox (preexec_fn, os.killpg)
- Remove unused variables and improve comments
- Add defensive documentation for hardcoded /tmp fallback

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

* test: add comprehensive unit tests for security fixes

- Add provider mapping tests in test_env_resolver.py
- Add sandbox isolation tests in test_subprocess_security.py
- Add logging regression tests in test_logging_regression.py

Tests validate:
- Provider-specific API key routing (no cross-contamination)
- Environment isolation with SecurityPolicy
- Resource limits enforcement via setrlimit
- Cross-platform compatibility (Windows/POSIX)
- Logging.basicConfig() removal from inc/models.py

All critical security gaps now have test coverage.

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

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

* fix: replace hardcoded secrets in tests with clearly fake placeholders

- Replace 'secret123', 'dbpass456', 'token789' with descriptive fake values
- Preserves test functionality while passing GitGuardian security checks
- Test logic checks for env var keys, not values, so functionality unchanged

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

* fix: replace hardcoded secrets in tests with clearly fake placeholders

Replace test placeholders that GitGuardian was flagging:
- 'fake-aws-key-for-testing' → 'not-a-real-aws-secret'
- 'fake-db-password-for-testing' → 'not-a-real-db-password'
- 'fake-api-token-for-testing' → 'not-a-real-api-token'

Test functionality is preserved since the regression test checks for
environment variable keys, not their values.

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

Co-Authored-By: Claude <[email protected]>

* fix: replace hardcoded secrets in tests with clearly fake placeholders

- Updated test values in test_subprocess_security.py to use obviously fake placeholders
- Changed 'not-a-real-*' format to 'FAKE_*_FOR_TESTING_ONLY' format
- Fixes GitGuardian security scanner detection of Generic Password patterns
- Test functionality preserved (tests environment variable keys, not values)

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-13 00:23:05 +01:00
AkhiChalasaniandAkhil Chalasani 6f2df7db2a docs: improve documentation (#1640)
Created CONTRIBUTING.md

Co-authored-by: Akhil Chalasani <[email protected]>
2026-06-12 23:44:01 +01:00
praisonai-triage-agent[bot]andMervin Praison 495d4604fe feat: remove deprecated tool_timeout and parallel_tool_calls parameters (#1902)
* feat: remove deprecated tool_timeout and parallel_tool_calls parameters

- Remove tool_timeout and parallel_tool_calls parameters from Agent constructor
- These are now fully consolidated into tool_config=ToolConfig() pattern
- Update tests to use new ToolConfig pattern
- Simplify _resolve_tool_config method
- Remove deprecation warning logic as parameters are fully removed

This completes the parameter consolidation effort following AGENTS.md §5.3

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

* fix: address reviewer feedback - restore ExecutionConfig.parallel_tool_calls support and update docs

- Fix critical regression where ExecutionConfig.parallel_tool_calls was ignored
- Update ToolConfig documentation examples to use tool_config parameter
- Fix test expectations for Agent.__init__ parameters (tool_timeout -> tool_config)
- Consolidate ToolConfig imports to module level in test files

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:43:31 +01:00
MervinPraison a4386796c9 Release v4.6.55 2026-06-12 23:39:35 +01:00
e1f9ae5b6b fix: prevent duplicate session messages from persist+auto_save and save_state (#1897)
* fix: prevent duplicate session messages from persist+auto_save and save_state

When memory=history and auto_save share a session_id, per-turn _persist_message
wrote messages that _auto_save_session then re-appended because
_auto_save_last_index was not updated.

Session.save_state() also re-appended the full chat history on every call
instead of replacing it atomically.

- Sync _auto_save_last_index in _persist_message when sessions match
- Use set_chat_history() in Session._save_agent_chat_histories for idempotent saves
- Add regression tests for both duplicate paths

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

* fix: address thread safety and fallback logging issues

- Wrap _auto_save_last_index increment in _history_lock for thread safety
- Add warning when session store lacks set_chat_history (duplicate risk)
- Add debug logging for empty message lists

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-12 23:21:15 +01:00
aa5315b90e fix: implement retry policy for tool execution with exponential backoff (fixes #1840) (#1852)
* fix: implement retry policy for tool execution with exponential backoff (fixes #1840)

- Add RetryPolicy integration to tool execution pipeline
- Implement retry logic in _execute_tool_with_circuit_breaker() and execute_tool_async()
- Add tool-level and agent-level retry policy support to Agent constructor
- Extend OnRetryInput hook event with tool-specific fields (tool_name, attempt, delay_ms, error_type)
- Add helper methods for retry policy resolution and error classification
- Support exponential backoff with jitter for transient failures (timeout, rate_limit, connection_error)
- Emit ON_RETRY hook events during retry attempts for observability
- Maintain backward compatibility with existing OnRetryInput fields

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

Co-Authored-By: Claude <[email protected]>

* fix: add missing circuit_open guard and optimize retry policy caching

- Add missing circuit_open guard to async retry path (addresses Greptile P1 issue)
- Move inline import os to module level for consistency
- Cache default RetryPolicy instance to avoid repeated allocation

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

* feat: comprehensive tool retry policy improvements and testing

- Fix RetryPolicy import in TYPE_CHECKING block
- Fix clone_for_channel to propagate tool_retry_policy
- Fix async hook emission to use executor instead of blocking event loop
- Add exception chaining (raise ... from e) for better error tracebacks
- Fix iterable guard in _get_tool_retry_policy for MCP tools
- Wire @tool(retry_policy=...) support on FunctionTool decorator
- Add comprehensive unit tests for sync/async retry functionality
- Add real agentic tests with flaky tools (satisfies AGENTS.md §9.4)
- Add CLI support with --tool-retry-* flags
- Add YAML configuration support for tool_retry_policy
- Test tool-level policy precedence and error classification

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

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

* feat: implement ToolConfig consolidation pattern to reduce Agent class bloat

- Add ToolConfig class following AGENTS.md §5.3 pattern (False=disabled, True=defaults, Config=custom)
- Consolidate tool_timeout, tool_retry_policy, parallel_tool_calls into single tool_config parameter
- Maintain full backward compatibility with deprecation warnings for legacy parameters
- Add ToolParam type alias and resolve_tools() function for type safety
- Update Agent constructor and clone_for_channel method to support consolidated config
- Tested with both new consolidated and legacy deprecated approaches

Follows AGENTS.md §5.3 parameter consolidation guidelines. Reduces Agent constructor bloat
while maintaining backward compatibility and following consistent SDK patterns.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:19:23 +01:00
b9d3ede110 fix: defer framework adapter creation until YAML is loaded (fixes #1877) (#1881)
* fix: defer framework adapter creation until YAML is loaded

- Fixes issue where AgentsGenerator crashes with empty framework before YAML is read
- Defers framework_adapter creation from __init__ to generate_crew_and_kickoff()
- Changes default YAML framework from 'crewai' to 'praisonai' for better onboarding
- Enables 'praisonai agents.yaml' to work without --framework flag

Fixes #1877

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* test: add mandatory tests for framework default change and async path parity

- Add test verifying YAML files without framework key default to praisonai (fixes #1877)
- Add test ensuring async path has same adapter.resolve() + init_observability() pattern as sync
- Add FRAMEWORK_DEFAULTS_CHANGE.md documenting breaking change migration

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:12:36 +01:00
MervinPraison 441b428cc9 Release v4.6.54 2026-06-12 21:11:21 +01:00
praisonai-triage-agent[bot]andMervin Praison bcae07cf58 feat: implement CC/BCC support for email agents (#1876)
* feat: implement CC/BCC support for email agents

- Add CC and BCC parameters to all email functions in email_tools.py
- Support both comma-separated strings and list formats for recipients
- Update AgentMail and SMTP/IMAP backends to handle multiple recipients
- Enhance EmailBot.send_message() to support CC/BCC in content dict
- Add comprehensive tests for new functionality
- Maintain full backward compatibility
- Follow PraisonAI protocol-driven architecture

Fixes #1874

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

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

* fix: resolve CC/BCC architecture issues identified in code review

- Fix critical race condition in EmailBot._send_smtp by passing recipients as parameter
- Add BCC header persistence in IMAP drafts to prevent loss on reopening
- Improve email list normalization to handle whitespace/empty entries in lists
- Add mandatory agentic tests with Agent.start() and real LLM interaction
- Remove duplicate test files (test_cc_functionality.py, simple_cc_test.py)
- Eliminate code duplication by reusing _parse_email_list in EmailBot

Addresses issues identified by CodeRabbit, Greptile, and Copilot reviewers.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-12 21:07:58 +01:00
praisonai-triage-agent[bot]andMervin Praison bf28787705 fix: add loop guardrails to prevent infinite tool calling (fixes #1886) (#1888)
* fix: add loop guardrails to prevent infinite tool calling in agent.chat() (fixes #1886)

- Added max_tool_calls_per_turn to ExecutionConfig (default: 10)
- Enhanced LLM.get_response() and get_response_async() with tool call counting
- Updated chat_mixin.py to pass guardrail limits to LLM methods
- Added clear error messages when tool call limit is exceeded
- Prevents broken tools from burning tokens with infinite loops

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

* fix: complete loop guardrail implementation

- Add max_tool_calls_per_turn to internal_params filter in llm.py
- Implement guardrail in get_response_stream method (was missing)
- Improve pre-batch check logic to be more precise with batch limiting
- Fix sys.exit(1) in test_loop_issue.py to use proper RuntimeError
- Fix API key placeholder in test_loop_guardrails.py for CI compatibility

Addresses all issues identified by Greptile review:
- Streaming path now has proper guardrails
- Parameter no longer leaks to LLM API
- Better batch size handling at limit boundaries
- Test improvements for CI/CD compatibility

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-12 21:06:04 +01:00
MervinPraison 5a8f4aedf8 docs: auto-update feature parity trackers [skip ci] 2026-06-12 20:05:39 +00:00
459a12a656 feat: implement real-time message steering for agents during execution (#1802)
* feat: implement real-time message steering for agents during execution

- Add MessageSteeringProtocol and AgentSteeringProtocol following protocol-driven architecture
- Implement MessageSteering class with priority-based message queuing
- Add SteeringMixin to Agent class with message_steering parameter
- Integrate steering checks into execution flow and tool execution
- Support mid-execution guidance with priority levels (normal, high, urgent, interrupt)
- Maintains backward compatibility with existing Agent API
- Zero overhead when message_steering=False (default)

Fixes #1799

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

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address critical bugs in message steering implementation

- Fix INTERRUPT detection in tool_execution.py (was using broken substring check)
- Fix rate limiter blocking urgent messages during tool execution
- Add INTERRUPT priority bypass in message queue (URGENT.value + 1)
- Add proper error handling in execution_mixin.py with graceful degradation
- Extend MessageSteeringProtocol with enabled property for contract compliance
- Move test file to proper tests/integration/ directory structure
- Add mandatory live agentic test with real LLM execution
- Improve prompt formatting with clear separators

Fixes critical bugs identified by Greptile, CodeRabbit reviewers.
All changes maintain backward compatibility.

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

* fix: comprehensive message steering improvements

- Wire steering in ALL execution paths (run, chat, achat, streaming)
- Move protocols to agent/protocols.py per AGENTS.md convention
- Remove duplicate test file and enhance live test with actual validation
- Fix INTERRUPT marker mismatch between tool_execution and SteeringMixin
- Improve priority handling with distinct INTERRUPT formatting

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

* feat: add CLI/YAML parity and examples for message steering

- Add --message-steering CLI flag support in wrapper package
- Add YAML agent config mapping (message_steering: true)
- Create comprehensive Python example showing all usage patterns
- Create YAML configuration example
- Implements AGENTS.md §7 requirement (Python + CLI + YAML support)

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-12 21:05:11 +01:00
MervinPraison d9224adc70 docs: auto-update api.md [skip ci] 2026-06-12 20:04:37 +00:00
82f1d8881b fix: resolve wrapper issues - duplicate arun definitions, ToolResolver cache bug, missing observability finalizer (#1896)
* fix: resolve wrapper issues - duplicate arun definitions, ToolResolver cache bug, missing observability finalizer (fixes #1895)

- Remove duplicate arun() definitions in framework_adapters/base.py
- Fix ToolResolver cache instantiation bug in tool_resolver.py
- Add finalize_observability() function to observability/hooks.py
- Update all framework adapters to use centralized observability finalizer
- Ensure AutoGen/AG2 adapters properly close AgentOps sessions

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

* fix: ensure observability finalization on exception paths in all adapters

- Add try/except blocks to AutoGenV4Adapter.arun() and AG2Adapter.run()
- Call finalize_observability with status='Failure' on exceptions
- Update all adapters to pass explicit status='Success' parameter
- Rename unused framework_tag parameter to _framework_tag for clarity

Fixes critical telemetry issue where AgentOps sessions remained 'in progress'
when exceptions occurred during framework execution.

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

Co-Authored-By: Claude <[email protected]>

* test: add regression tests for wrapper layer fixes in PR #1896

- Add unit tests for ToolResolver cache instantiate=True fast-path fix
- Add tests for observability finalization on adapter exception paths
- Validate framework adapter exception handling structure
- Test observability hooks error handling and status parameters
- Ensure comprehensive test coverage per AGENTS.md section 9 requirements

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-12 21:04:21 +01:00
ca07ebf813 fix: Agent handoff tool security boundary to prevent privilege escalation (#1848)
* fix: implement agent handoff tool security boundary to prevent privilege escalation (fixes #1842)

- Add HandoffToolPolicy dataclass with 'intersect' (secure by default) and 'passthrough' (legacy) modes
- Add blocked_tools list for tools that should never be passed through handoffs
- Update HandoffConfig to include tool_policy field with secure defaults
- Implement tool boundary enforcement in all handoff execution paths:
  - execute_programmatic(): programmatic handoffs
  - execute_async(): async handoffs with concurrency control
  - to_tool_function(): LLM-driven handoffs via tool calls
- Add convenience parameters to handoff() function: tool_policy_mode, blocked_tools
- Maintain full backward compatibility - existing code continues working unchanged
- Default mode is 'intersect' for security by default - sub-agents only get tools that both source and target agents have
- Optional 'passthrough' mode preserves legacy behavior when explicitly requested

Security impact:
- Prevents privilege escalation where restricted gatekeeper agents could accidentally grant dangerous tools to sub-agents
- Tool intersection computed at handoff boundary without mutating target agent's original tool list
- Blocked tools are always filtered regardless of intersection mode
- Safe by default - new tool policy applies automatically to protect against security gaps

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

* fix: critical handoff tool security bypass and blocked_tools logic

- Fix empty tools list security bypass in intersect mode
- Return None for unrestricted passthrough mode without blocked tools
- Return [] to enforce empty toolset (security boundary) in intersect mode
- Fix blocked_tools falsy logic to allow explicit empty list clearing

Security Impact:
- Prevents privilege escalation when orchestrator agent has no tools
- Ensures intersect mode properly restricts target agent capabilities
- Maintains backward compatibility with passthrough mode

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

* fix: P0 HandoffToolPolicy security boundary enforcement

Critical security fix prevents privilege escalation during agent handoffs:

🔒 Security Fixes:
- Fix tools=[] vs tools=None distinction in chat_mixin.py (P0 vulnerability)
- Enforce HandoffToolPolicy intersect mode security boundaries
- Handle None tools attributes to prevent TypeError
- Fix blocked_tools=[] explicit empty list handling

✅ New Features:
- Add comprehensive HandoffToolPolicy unit tests (25 test cases)
- Export HandoffToolPolicy in __init__.py for public API
- Add security documentation and Mintlify docs stub

🛡️ Security Model:
- Default: intersect mode (secure by default - only shared tools)
- Legacy: passthrough mode (opt-in - full toolset with blocklist)
- Boundary: tools=[] enforces explicit tool denial
- Defense: Multi-layer privilege escalation prevention

Fixes #1842

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

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

* fix: Windows CI syntax error - replace Unicode characters with ASCII equivalents

Replace em-dash (—) and right arrow (→) characters in chat_mixin.py comments with ASCII equivalents to prevent Windows Python parser issues.

Fixes IndentationError on Windows CI around line 2296.

* Delete docs/handoff-security.mdx

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 21:04:02 +01:00
2b2443ff63 fix: resolve sync/async path drift, silent DB writes, and adapter inconsistency (#1884)
* fix: resolve sync/async path drift, silent DB writes, and adapter inconsistency (fixes #1883)

- Extract shared pipeline logic in AgentsGenerator to eliminate sync/async drift
- Add _init_stores() to all DB write hooks to prevent silent data loss
- Add _resolve_llm() to BaseFrameworkAdapter for consistent string/dict llm handling
- Fix CrewAI adapter to support string-form llm configuration like PraisonAI adapter

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

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

* fix: address IndexError, dead code, and missing store init issues

- Fix IndexError risk in BaseFrameworkAdapter._resolve_llm() by checking llm_config length
- Remove unused 'field' parameter from _resolve_llm() method signature
- Fix duplicate arun() method definition in BaseFrameworkAdapter
- Add missing _init_stores() calls to all tracing handlers (on_trace_start, on_trace_end, on_span_start, on_span_end)

Addresses feedback from CodeRabbit and Greptile reviews.

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

* test: add unit tests for wrapper layer architectural fixes

- Add DB adapter tracing hooks initialization tests (7 tests)
  * Tests _init_stores() calls in on_trace_start/end, on_span_start/end
  * Validates prevention of silent data loss when stores uninitialized
  * Tests graceful degradation without state_url configuration

- Add framework adapter _resolve_llm() IndexError guard tests (5 tests)
  * Tests safe handling of empty llm_config lists and None values
  * Validates length checks before accessing llm_config[0]
  * Tests CrewAI adapter string LLM compatibility

Covers architectural fixes from issue #1883:
1. Silent DB writes prevention via store initialization
2. IndexError crashes prevention via safe access patterns
3. String LLM support via unified _resolve_llm() helper

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

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-12 19:10:23 +01:00
praisonai-triage-agent[bot]andMervin Praison c8938c6aeb fix: persistence layer schema drift, async contract violations, and registry boilerplate (#1829)
* fix: persistence layer schema drift, async contract violations, and registry boilerplate (fixes #1822)

- Add AsyncConversationStore protocol to eliminate broken async-store contract
- Replace runtime introspection with type-based dispatch in orchestrator
- Create SQL conversation store base template to prevent schema drift
- Unify retry logic across all SQL backends (MySQL/Turso/SingleStore now get serverless retry)
- Add PluginRegistry.default() to eliminate copy-pasted singleton boilerplate
- Reduce postgres.py from 485→235 lines (~52% reduction)
- Ensure schema consistency between sync/async stores

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

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

* fix: critical bugs in persistence layer preventing PostgreSQL backend from working

- Fixed PostgreSQL property conflict by making sessions_table/messages_table properties in base class
- Fixed multi-statement DDL by separating schema creation in postgres.py
- Fixed broken connection retry logic by adding close parameter to _put_conn
- Fixed JSON handling to support psycopg2's automatic JSONB decoding
- Added SQL injection prevention with parameter validation for limit/offset
- Fixed empty message_ids handling in delete_messages
- Fixed registry lock initialization race condition with shared guard lock
- Fixed LLM registry reset to use __dict__ membership check
- Added concurrency protection for async SQLite initialization
- Removed artifact files: fix_async_postgres.py, postgres_backup.py, postgres_new.py

All critical issues identified by reviewers are now resolved.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 19:10:02 +01:00
153 changed files with 6634 additions and 1815 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Contributing to PraisonAI
Thank you for your interest! Here's how to get started:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## Guidelines
- Follow existing code style
- Add tests for new features
- Update documentation as needed
+1 -1
View File
@@ -357,7 +357,7 @@ Methods:
* <code title="class Handoff">Handoff.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_tool_function</a>(source_agent: 'Agent') -> Callable</code>
* <code title="class Handoff">Handoff.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">tool_description</a>() -> str</code>
* <code title="class Handoff">Handoff.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">tool_name</a>() -> str</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">handoff</a>(agent: 'Agent', tool_name_override: Optional[str] = None, tool_description_override: Optional[str] = None, on_handoff: Optional[Callable] = None, input_type: Optional[type] = None, input_filter: Optional[Callable[[HandoffInputData], HandoffInputData]] = None, config: Optional[HandoffConfig] = None, context_policy: Optional[str] = None, timeout_seconds: Optional[float] = None, max_concurrent: Optional[int] = None, detect_cycles: Optional[bool] = None, max_depth: Optional[int] = None) -> Handoff</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">handoff</a>(agent: 'Agent', tool_name_override: Optional[str] = None, tool_description_override: Optional[str] = None, on_handoff: Optional[Callable] = None, input_type: Optional[type] = None, input_filter: Optional[Callable[[HandoffInputData], HandoffInputData]] = None, config: Optional[HandoffConfig] = None, context_policy: Optional[str] = None, timeout_seconds: Optional[float] = None, max_concurrent: Optional[int] = None, detect_cycles: Optional[bool] = None, max_depth: Optional[int] = None, tool_policy_mode: Optional[Literal['intersect', 'passthrough']] = None, blocked_tools: Optional[List[str]] = None) -> Handoff</code>
* <code title="class handoff_filters">handoff_filters.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">compress_history</a>(data: HandoffInputData) -> HandoffInputData</code>
* <code title="class handoff_filters">handoff_filters.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">keep_last_n_messages</a>(n: int) -> Callable[[HandoffInputData], HandoffInputData]</code>
* <code title="class handoff_filters">handoff_filters.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">remove_all_tools</a>(data: HandoffInputData) -> HandoffInputData</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.6.53" \
"praisonai>=4.6.55" \
"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.6.53" \
"praisonai>=4.6.55" \
"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.6.53" \
"praisonai>=4.6.55" \
"praisonai[ui]" \
"praisonai[crewai]"
+1 -1
View File
@@ -52,7 +52,7 @@ def example_with_config():
instructions="You are a research assistant",
memory=MemoryConfig(
backend="file",
memory={"user_id": "researcher_001"},
user_id="researcher_001",
auto_memory=True,
),
reflection=ReflectionConfig(
+6 -1
View File
@@ -12,10 +12,15 @@ from praisonaiagents import Agent
from praisonaiagents.approval import AgentApproval
from praisonaiagents.tools.shell_tools import execute_command
reviewer = AgentApproval(
reviewer_agent = Agent(
name="CommandReviewer",
instructions="Only approve read-only commands like 'ls' or 'cat'. Deny destructive commands.",
)
reviewer = AgentApproval(
approver_agent=reviewer_agent,
)
agent = Agent(
name="DevOps",
instructions="You are a DevOps assistant. Use shell tools when asked.",
@@ -2,7 +2,7 @@
from praisonaiagents import Agent
from praisonaiagents import AgentFlow, Task
from praisonaiagents.workflows.workflow_configs import (
WorkflowOutputConfig, WorkflowPlanningConfig, WorkflowMemoryConfig,
WorkflowPlanningConfig, WorkflowMemoryConfig,
)
# Full-featured workflow with all consolidated params
@@ -32,7 +32,7 @@ workflow = AgentFlow(
),
],
# Workflow-level consolidated params
output=WorkflowOutputConfig(output="verbose", stream=True),
output="verbose",
planning=WorkflowPlanningConfig(enabled=True, reasoning=True),
memory=WorkflowMemoryConfig(backend="file"),
# Agent-like params
+1 -1
View File
@@ -10,7 +10,7 @@ from praisonaiagents import Agent
# Basic: Enable web search with preset
agent = Agent(
instructions="You are a research assistant with web access.",
web="duckduckgo", # Presets: duckduckgo, tavily, google, bing, serper
web="search_only", # Compatible across models without web_fetch
)
if __name__ == "__main__":
@@ -80,7 +80,7 @@ Be precise and quote exact values when asked about codes or numbers.""",
print(f"\nAnswer: {response}\n")
# Verify the answer contains the unique code
if "ZEBRA-71" in response.upper():
if "ZEBRA-71" in str(response or "").upper():
print("✅ VERIFIED: Agent correctly retrieved the unique code from context!\n")
else:
print("❌ WARNING: Agent did not find the code - check retrieval\n")
@@ -104,7 +104,7 @@ Be precise and quote exact values when asked about codes or numbers.""",
response = agent.chat("What is the security incident code?")
print(f"\nAnswer: {response}\n")
if "HAWK-88" in response.upper():
if "HAWK-88" in str(response or "").upper():
print("✅ VERIFIED: Agent correctly retrieved the security code!\n")
else:
print("❌ WARNING: Agent did not find the code - check retrieval\n")
@@ -17,8 +17,10 @@ from praisonaiagents import Agent
agent = Agent(
name="CockroachDB Agent",
instructions="You are a helpful assistant.",
db={"database_url": os.getenv("COCKROACHDB_URL")},
session_id="crdb-demo-session",
memory={
"session_id": "crdb-demo-session",
"db": {"database_url": os.getenv("COCKROACHDB_URL")},
},
)
result = agent.start("What is distributed SQL? Explain in 2 sentences.")
@@ -18,8 +18,10 @@ from praisonaiagents import Agent
agent = Agent(
name="Neon Agent",
instructions="You are a helpful assistant.",
db={"database_url": os.getenv("NEON_DATABASE_URL")},
session_id="neon-demo-session",
memory={
"session_id": "neon-demo-session",
"db": {"database_url": os.getenv("NEON_DATABASE_URL")},
},
)
# Option 2: Direct URL
@@ -20,8 +20,10 @@ from praisonaiagents import Agent
agent = Agent(
name="Supabase Agent",
instructions="You are a helpful assistant.",
db={"database_url": os.getenv("SUPABASE_DATABASE_URL")},
session_id="supabase-demo-session",
memory={
"session_id": "supabase-demo-session",
"db": {"database_url": os.getenv("SUPABASE_DATABASE_URL")},
},
)
result = agent.start("What is Supabase? Explain in 2 sentences.")
@@ -18,11 +18,13 @@ from praisonaiagents import Agent
agent = Agent(
name="Turso Agent",
instructions="You are a helpful assistant.",
db={
"database_url": os.getenv("TURSO_DATABASE_URL"),
"auth_token": os.getenv("TURSO_AUTH_TOKEN"),
memory={
"session_id": "turso-demo-session",
"db": {
"database_url": os.getenv("TURSO_DATABASE_URL"),
"auth_token": os.getenv("TURSO_AUTH_TOKEN"),
},
},
session_id="turso-demo-session",
)
result = agent.start("What is edge computing? Explain in 2 sentences.")
+1 -2
View File
@@ -10,8 +10,7 @@ from praisonaiagents import Agent
agent = Agent(
name="FlexBot",
instructions="You are a helpful assistant.",
llm="gpt-4o-mini",
llm_config={"configurable": True}
llm={"model": "gpt-4o-mini", "configurable": True},
)
if __name__ == "__main__":
+1 -1
View File
@@ -21,7 +21,7 @@ agent = Agent(
name="ContextBot",
instructions="You help show context information.",
tools=[show_context],
session_id="my-session-123"
memory={"session_id": "my-session-123"},
)
if __name__ == "__main__":
+13 -3
View File
@@ -19,6 +19,16 @@ from praisonaiagents import Agent, AgentTeam
# Initialize observability
obs.init()
def set_span_attr(span, key, value):
"""Set span attribute safely across observability implementations."""
if hasattr(span, "set_attribute"):
span.set_attribute(key, value)
return
attrs = getattr(span, "attributes", None)
if attrs is not None:
attrs[key] = value
# Create agents
researcher = Agent(
name="Researcher",
@@ -37,13 +47,13 @@ with obs.trace("content-creation", metadata={"workflow": "research-write"}):
# Research phase
with obs.span("research-phase", kind=SpanKind.AGENT) as research_span:
research_span.attributes["agent"] = "Researcher"
set_span_attr(research_span, "agent", "Researcher")
research_result = researcher.chat("Research the benefits of AI in healthcare")
# Writing phase
with obs.span("writing-phase", kind=SpanKind.AGENT) as write_span:
write_span.attributes["agent"] = "Writer"
write_span.attributes["input_length"] = len(str(research_result))
set_span_attr(write_span, "agent", "Writer")
set_span_attr(write_span, "input_length", len(str(research_result)))
final_content = writer.chat(f"Write a blog post based on: {research_result}")
print("Final Content:")
+4 -2
View File
@@ -52,8 +52,10 @@ agent = Agent(
instructions="You are a helpful assistant with access to technical documentation.",
knowledge={
"sources": [doc_path],
"vector_store": "qdrant",
"url": "http://localhost:6333",
"vector_store": {
"provider": "qdrant",
"url": "http://localhost:6333",
},
}
)
+8 -5
View File
@@ -13,7 +13,8 @@ Expected output:
- Session can be resumed later with same session_id
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
# Create database connection (PostgreSQL + Redis)
my_db = db(
@@ -25,10 +26,12 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be concise.",
db=my_db,
# session_id is optional - defaults to per-hour ID (YYYYMMDDHH-hash)
# Set explicitly for session continuity across runs:
session_id="my-persistent-session"
memory={
"db": my_db,
# session_id is optional - defaults to per-hour ID (YYYYMMDDHH-hash)
# Set explicitly for session continuity across runs:
"session_id": "my-persistent-session",
},
)
# Chat - messages are automatically persisted
+6 -3
View File
@@ -12,7 +12,8 @@ Expected output:
- Export shows full session data
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
def get_weather(city: str) -> str:
"""Get weather for a city."""
@@ -29,8 +30,10 @@ agent = Agent(
name="WeatherBot",
instructions="You help with weather. Use the get_weather tool when asked.",
tools=[get_weather],
db=my_db,
session_id="weather-session-001"
memory={
"db": my_db,
"session_id": "weather-session-001",
},
)
# First chat - triggers run tracking
+6 -3
View File
@@ -13,7 +13,8 @@ Expected output:
Agent responds with state persisted to Redis
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
print("=== Redis State Store (Agent-First) ===")
@@ -26,8 +27,10 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
db=my_db,
session_id="redis-state-session"
memory={
"db": my_db,
"session_id": "redis-state-session",
},
)
# Chat - state is automatically managed via Redis
+6 -3
View File
@@ -11,7 +11,8 @@ Expected output:
- Second run: Resumes with history
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
# Create database
my_db = db(
@@ -25,8 +26,10 @@ SESSION_ID = "resume-demo-session"
agent = Agent(
name="MemoryBot",
instructions="You remember our conversation. Be helpful and concise.",
db=my_db,
session_id=SESSION_ID
memory={
"db": my_db,
"session_id": SESSION_ID,
},
)
# Check if this is a new or resumed session
+6 -3
View File
@@ -26,7 +26,8 @@ Expected Output:
Messages persisted: 2
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
# Create database adapter (simplified import)
db_instance = db(
@@ -37,8 +38,10 @@ db_instance = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Keep responses brief.",
db=db_instance,
session_id="demo-session-001", # Same session_id = resume conversation
memory={
"db": db_instance,
"session_id": "demo-session-001", # Same session_id = resume conversation
},
output="silent"
)
+6 -3
View File
@@ -11,7 +11,8 @@ Expected output:
- Data persisted to local SQLite file
"""
from praisonaiagents import Agent, db
from praisonaiagents import Agent
from praisonaiagents.db import db
import tempfile
import os
@@ -25,8 +26,10 @@ print(f"Database: {db_path}")
agent = Agent(
name="LocalBot",
instructions="You are a helpful assistant.",
db=my_db,
session_id="local-session"
memory={
"db": my_db,
"session_id": "local-session",
},
)
# Chat
+1 -1
View File
@@ -57,8 +57,8 @@ def main():
agent = Agent(
name="SecureAgent",
instructions="You are a file management assistant.",
policy=engine
)
agent.policy = engine
print("\n--- Agent with Policy Enforcement Created ---")
print(f"Agent: {agent.name}")
@@ -60,9 +60,10 @@ agents_system = AgentTeam(
process="sequential", output="verbose"
)
print("Starting hierarchical agent generation...")
result = agents_system.start()
if __name__ == "__main__":
print("Starting hierarchical agent generation...")
result = agents_system.start()
print(f"\nHierarchical Result: {result[:200]}...")
print("\n✅ AutoAgents hierarchical generation complete!")
print("Demonstrated coordinated hierarchical workflow between manager and specialist agents.")
print(f"\nHierarchical Result: {str(result)[:200]}...")
print("\n✅ AutoAgents hierarchical generation complete!")
print("Demonstrated coordinated hierarchical workflow between manager and specialist agents.")
+3 -2
View File
@@ -1,4 +1,5 @@
from praisonaiagents import Agent
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
agent.launch(port=8080, protocol="mcp")
if __name__ == "__main__":
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
agent.launch(port=8080, protocol="mcp", path="/")
+11 -2
View File
@@ -90,9 +90,18 @@ def main():
if result:
print("\n🔍 Analysis Results:")
for task_id, task_result in result["task_results"].items():
task_results = result.get("task_results") if isinstance(result, dict) else result
if isinstance(task_results, dict):
iterable = task_results.items()
elif isinstance(task_results, list):
iterable = enumerate(task_results)
else:
iterable = [("result", task_results)]
for task_id, task_result in iterable:
print(f"\nTask {task_id}:")
print(task_result.raw)
raw_value = getattr(task_result, "raw", None)
print(raw_value if raw_value is not None else str(task_result))
else:
print("❌ Failed to capture and analyze camera feed")
+1 -1
View File
@@ -75,7 +75,7 @@ class ContinuousCameraMonitor:
agents=[self.vision_agent],
tasks=[task],
process="sequential",
verbose=0 # Reduced verbosity for continuous monitoring
output="silent", # Reduced verbosity for continuous monitoring
)
return agents.start()
+11 -2
View File
@@ -148,10 +148,19 @@ def main():
"scene_analysis": "🌍 SCENE ANALYSIS"
}
for task_id, task_result in result["task_results"].items():
task_results = result.get("task_results") if isinstance(result, dict) else result
if isinstance(task_results, dict):
iterable = task_results.items()
elif isinstance(task_results, list):
iterable = enumerate(task_results)
else:
iterable = [("result", task_results)]
for task_id, task_result in iterable:
print(f"\n{agent_names.get(task_id, f'Task {task_id}')}:")
print("-" * 40)
print(task_result.raw)
raw_value = getattr(task_result, "raw", None)
print(raw_value if raw_value is not None else str(task_result))
print("\n" + "="*60)
print("✅ Multi-agent analysis complete!")
@@ -34,5 +34,5 @@ print(f"\nChanged to: {handler.get_mode()}")
print("File edits now auto-approved, shell commands still require approval")
# Get statistics
stats = handler.get_stats()
stats = manager.get_stats()
print(f"\nStats: {stats}")
+1 -1
View File
@@ -31,4 +31,4 @@ print(f"Total cost: ${summary['total_cost']:.4f}")
print(f"Avg cost/request: ${summary['avg_cost_per_request']:.4f}")
# Display formatted (uses Rich if available)
handler.display_summary()
handler.print_summary()
@@ -41,9 +41,9 @@ def my_custom_handler(args, context):
custom_cmd = SlashCommand(
name="mycommand",
description="My custom command",
handler=my_custom_handler,
kind=CommandKind.ACTION,
aliases=["mc"]
action=my_custom_handler,
kind=CommandKind.CUSTOM,
alt_names=["mc"]
)
handler.register(custom_cmd)
@@ -16,7 +16,8 @@ agents = AgentTeam(
agents=[agent],
tasks=[task],
process="workflow",
max_iter=30
execution={"max_iter": 30},
)
agents.start()
if __name__ == "__main__":
agents.start()
@@ -5,7 +5,8 @@ This example demonstrates self-reflection capabilities using PraisonAI's
built-in reflection features for iterative improvement and quality optimization.
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents import Agent, Task
from praisonaiagents.agents import AgentTeam
from praisonaiagents.tools import internet_search
print("=== Self-Reflection Optimization Patterns Example ===\n")
@@ -33,9 +34,16 @@ reflection_task = Task(
)
# Run with self-reflection optimization
print("Starting self-reflection optimization demonstration...")
result = reflection_agent.execute_task(reflection_task)
if __name__ == "__main__":
print("Starting self-reflection optimization demonstration...")
agents = AgentTeam(
agents=[reflection_agent],
tasks=[reflection_task],
process="sequential",
output="verbose",
)
result = agents.start()
print(f"\nSelf-Reflection Result: {result[:200]}...")
print("\n✅ Self-reflection optimization complete!")
print("Agent demonstrated iterative improvement through built-in self-reflection capabilities.")
print(f"\nSelf-Reflection Result: {str(result)[:200]}...")
print("\n✅ Self-reflection optimization complete!")
print("Agent demonstrated iterative improvement through built-in self-reflection capabilities.")
@@ -61,7 +61,6 @@ def example_with_wrappers():
role="Data Analyst",
goal="Analyze data and provide insights",
llm="gpt-4o-mini",
request_id="request_2" # Unique request ID
)
# Operations are automatically tracked
+4 -2
View File
@@ -132,7 +132,9 @@ agents = AgentTeam(
agents=[qa_generator, total_questions_evaluator, cot_generator, upload_to_huggingface],
tasks=[generate_task, evaluate_total_questions_task, generate_cot_task, upload_to_huggingface_task],
process="workflow",
max_iter=30, output="minimal"
execution={"max_iter": 30},
output="minimal",
)
agents.start()
if __name__ == "__main__":
agents.start()
+3 -3
View File
@@ -18,7 +18,7 @@ openai_profile = AuthProfile(
provider="openai",
api_key=os.environ.get("OPENAI_API_KEY", "sk-..."),
priority=1, # Highest priority
rate_limit=100,
rate_limit_rpm=100,
)
anthropic_profile = AuthProfile(
@@ -26,7 +26,7 @@ anthropic_profile = AuthProfile(
provider="anthropic",
api_key=os.environ.get("ANTHROPIC_API_KEY", "sk-ant-..."),
priority=2, # Second priority
rate_limit=50,
rate_limit_rpm=50,
)
groq_profile = AuthProfile(
@@ -34,7 +34,7 @@ groq_profile = AuthProfile(
provider="groq",
api_key=os.environ.get("GROQ_API_KEY", "gsk-..."),
priority=3, # Third priority (fast but limited)
rate_limit=30,
rate_limit_rpm=30,
)
# Configure failover behavior
+2 -2
View File
@@ -105,13 +105,13 @@ def main():
agents=[researcher, retriever],
tasks=[store_task, verify_task, query_task, query_both_task], # Use same verbose level as memory
memory={
"backend": "sqlite",
"embedder": {
"provider": "openai",
"config": {
"model": "text-embedding-3-small"
}
}
},
"config": {"provider": "sqlite"},
}
)
+16 -6
View File
@@ -49,10 +49,20 @@ agents = AgentTeam(
process="sequential"
)
# Run all tasks
result = agents.start()
if __name__ == "__main__":
# Run all tasks
result = agents.start()
# Print results
for task_id, task_result in result["task_results"].items():
print(f"\nTask {task_id} Result:")
print(task_result.raw)
# Print results
task_results = result.get("task_results") if isinstance(result, dict) else result
if isinstance(task_results, dict):
iterable = task_results.items()
elif isinstance(task_results, list):
iterable = enumerate(task_results)
else:
iterable = [("result", task_results)]
for task_id, task_result in iterable:
print(f"\nTask {task_id} Result:")
raw_value = getattr(task_result, "raw", None)
print(raw_value if raw_value is not None else str(task_result))
@@ -6,6 +6,7 @@ built-in validation and safety mechanisms for secure agent operations.
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.config.feature_configs import GuardrailConfig
from praisonaiagents.tools import internet_search
print("=== Production Guardrails Patterns Example ===\n")
@@ -38,8 +39,8 @@ production_agent = Agent(
goal="Generate safe, compliant content for production use",
backstory="Production-ready agent with built-in safety and compliance checks",
tools=[internet_search],
guardrails=content_guardrail,
max_retries=3)
guardrails=GuardrailConfig(validator=content_guardrail, max_retries=3),
)
# Create secure task with validation
secure_task = Task(
+1 -1
View File
@@ -40,7 +40,7 @@ agent = Agent(
"execute_command"
],
memory=True,
web_search=True,
web=True,
auto_approve_tools=True,
)
@@ -14,30 +14,33 @@ agent = Agent(name="env-mgmt", backend=managed)
# First call creates agent + environment + session
agent.start("Say hello briefly")
# Access the underlying Anthropic client for environment management
client = managed._get_client()
env_id = managed.environment_id
print(f"Environment ID: {env_id}")
if hasattr(managed, "_get_client"):
# Access the underlying Anthropic client for environment management
client = managed._get_client()
env_id = managed.environment_id
print(f"Environment ID: {env_id}")
# List all environments
environments = client.beta.environments.list()
print(f"\nTotal environments: {len(environments.data)}")
for env in environments.data[:5]:
print(f" {env.id} | {env.name} | {getattr(env, 'status', 'active')}")
# List all environments
environments = client.beta.environments.list()
print(f"\nTotal environments: {len(environments.data)}")
for env in environments.data[:5]:
print(f" {env.id} | {env.name} | {getattr(env, 'status', 'active')}")
# Retrieve a specific environment
env = client.beta.environments.retrieve(env_id)
print(f"\nRetrieved: {env.id} | {env.name}")
# Retrieve a specific environment
env = client.beta.environments.retrieve(env_id)
print(f"\nRetrieved: {env.id} | {env.name}")
# Archive (read-only, existing sessions continue)
archived = client.beta.environments.archive(env_id)
print(f"Archived: {archived.id} | status: {getattr(archived, 'status', 'archived')}")
# Archive (read-only, existing sessions continue)
archived = client.beta.environments.archive(env_id)
print(f"Archived: {archived.id} | status: {getattr(archived, 'status', 'archived')}")
# Delete (only if no sessions reference it — may fail if sessions exist)
try:
client.beta.environments.delete(env_id)
print(f"Deleted: {env_id}")
except Exception as e:
print(f"Delete skipped (expected if sessions exist): {e}")
# Delete (only if no sessions reference it — may fail if sessions exist)
try:
client.beta.environments.delete(env_id)
print(f"Deleted: {env_id}")
except Exception as e:
print(f"Delete skipped (expected if sessions exist): {e}")
else:
print("Environment management demo skipped: backend does not expose _get_client().")
print("\nEnvironment management complete.")
+6 -7
View File
@@ -15,13 +15,6 @@ Protocol: MCP 2025-11-25
from praisonaiagents import Agent, MCP
# Agent with resumability support (automatic)
agent = Agent(
name="Resumable Assistant",
instructions="You are a helpful assistant with resumable connections.",
tools=MCP("https://api.example.com/mcp")
)
# Resumability is automatic - the MCP client:
# 1. Tracks event IDs from SSE streams (id: field)
# 2. Stores last event ID per stream
@@ -29,6 +22,12 @@ agent = Agent(
# 4. Respects retry delay from server (retry: field)
if __name__ == "__main__":
agent = Agent(
name="Resumable Assistant",
instructions="You are a helpful assistant with resumable connections.",
tools=MCP("https://api.example.com/mcp"),
)
print("MCP Resumability Example")
print("=" * 40)
print()
@@ -15,13 +15,6 @@ Protocol: MCP 2025-11-25
from praisonaiagents import Agent, MCP
# Basic agent with automatic session management
agent = Agent(
name="Session Assistant",
instructions="You are a helpful assistant with session support.",
tools=MCP("https://api.example.com/mcp")
)
# Session management is automatic - the MCP client:
# 1. Receives Mcp-Session-Id from server during initialization
# 2. Includes session ID in all subsequent requests
@@ -29,6 +22,12 @@ agent = Agent(
# 4. Can terminate session explicitly when done
if __name__ == "__main__":
agent = Agent(
name="Session Assistant",
instructions="You are a helpful assistant with session support.",
tools=MCP("https://api.example.com/mcp"),
)
print("MCP Session Management Example")
print("=" * 40)
print()
+24 -27
View File
@@ -16,34 +16,31 @@ Protocol: MCP 2025-11-25
from praisonaiagents import Agent, MCP
# Basic Streamable HTTP connection
agent = Agent(
name="HTTP Stream Assistant",
instructions="You are a helpful assistant connected via Streamable HTTP.",
tools=MCP("https://api.example.com/mcp")
)
# With authentication headers
agent_auth = Agent(
name="Authenticated Assistant",
instructions="You are a helpful assistant with authenticated connection.",
tools=MCP(
"https://api.example.com/mcp",
headers={"Authorization": "Bearer your-token"}
)
)
# With timeout and debug
agent_debug = Agent(
name="Debug Assistant",
tools=MCP(
"https://api.example.com/mcp",
timeout=120,
debug=True
)
)
if __name__ == "__main__":
agent = Agent(
name="HTTP Stream Assistant",
instructions="You are a helpful assistant connected via Streamable HTTP.",
tools=MCP("https://api.example.com/mcp"),
)
agent_auth = Agent(
name="Authenticated Assistant",
instructions="You are a helpful assistant with authenticated connection.",
tools=MCP(
"https://api.example.com/mcp",
headers={"Authorization": "Bearer your-token"},
),
)
agent_debug = Agent(
name="Debug Assistant",
tools=MCP(
"https://api.example.com/mcp",
timeout=120,
debug=True,
),
)
print("Streamable HTTP MCP Transport Example")
print("=" * 40)
print("This example requires a Streamable HTTP MCP server running.")
+26 -29
View File
@@ -16,36 +16,33 @@ Protocol: SEP-1288 (WebSocket Transport)
from praisonaiagents import Agent, MCP
# Basic WebSocket connection
agent = Agent(
name="WebSocket Assistant",
instructions="You are a helpful assistant connected via WebSocket.",
tools=MCP("ws://localhost:8080/mcp")
)
# Secure WebSocket with authentication
agent_secure = Agent(
name="Secure WebSocket Assistant",
instructions="You are a helpful assistant with secure WebSocket connection.",
tools=MCP(
"wss://api.example.com/mcp",
auth_token="Bearer your-secret-token",
timeout=60
)
)
# WebSocket with custom options
agent_custom = Agent(
name="Custom WebSocket Assistant",
tools=MCP(
"wss://api.example.com/mcp",
auth_token="your-token",
timeout=120,
debug=True
)
)
if __name__ == "__main__":
agent = Agent(
name="WebSocket Assistant",
instructions="You are a helpful assistant connected via WebSocket.",
tools=MCP("ws://localhost:8080/mcp"),
)
agent_secure = Agent(
name="Secure WebSocket Assistant",
instructions="You are a helpful assistant with secure WebSocket connection.",
tools=MCP(
"wss://api.example.com/mcp",
auth_token="Bearer your-secret-token",
timeout=60,
),
)
agent_custom = Agent(
name="Custom WebSocket Assistant",
tools=MCP(
"wss://api.example.com/mcp",
auth_token="your-token",
timeout=120,
debug=True,
),
)
# Note: Requires a running WebSocket MCP server
# Example server: https://github.com/modelcontextprotocol/servers
@@ -12,8 +12,8 @@ print("=== Advanced Graph Memory Integration Example ===\n")
# Configure simple graph memory
memory_config = {
"provider": "mem0",
"config": {
"provider": "mem0",
"vector_store": {
"provider": "chroma",
"config": {
@@ -12,8 +12,8 @@ print("=== Advanced Memory Search and Optimization Example ===\n")
# Configure optimized memory settings
memory_config = {
"provider": "mem0",
"config": {
"provider": "mem0",
"vector_store": {
"provider": "chroma",
"config": {
@@ -86,13 +86,13 @@ def main():
agents=[researcher, retriever],
tasks=[store_task, retrieve_task],
memory={
"backend": "sqlite",
"embedder": {
"provider": "gemini",
"config": {
"model": "text-embedding-004",
}
}
},
"config": {"provider": "sqlite"},
}
)
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Message Steering Example
Demonstrates real-time message steering for agents during execution.
Shows how to send guidance messages to agents while they're running tasks.
Usage:
# Python API
python message_steering_example.py
# CLI with steering enabled
praisonai --message-steering "Write a detailed report about AI trends"
# YAML (add message_steering: true to agent config)
"""
import asyncio
import threading
import time
from praisonaiagents import Agent
def basic_message_steering():
"""Basic message steering example - enable steering and send guidance."""
print("🎯 Basic Message Steering Example")
print("=" * 50)
# Create agent with message steering enabled
agent = Agent(
name="researcher",
instructions="You are a helpful research assistant",
message_steering=True, # Enable real-time message steering
llm="gpt-4o-mini"
)
print("✅ Agent created with message steering enabled")
print(f"📊 Steering status: {agent.get_steering_status()}")
# Send some guidance
msg_id_1 = agent.steer("Focus on recent developments from 2024")
msg_id_2 = agent.steer("Keep response under 200 words", priority=10)
print(f"📨 Sent steering messages: {msg_id_1}, {msg_id_2}")
print(f"📊 Steering status: {agent.get_steering_status()}")
# Execute task - steering will be automatically injected
result = agent.start("Summarize the latest AI trends")
print("\n📝 Result:")
print(result)
def threaded_message_steering():
"""Advanced example - send steering messages while agent is running."""
print("\n🚀 Threaded Message Steering Example")
print("=" * 50)
agent = Agent(
name="writer",
instructions="You are a content writer",
message_steering=True,
llm="gpt-4o-mini"
)
result_holder = [None]
def run_agent():
"""Run agent in background thread."""
result = agent.start("Write a comprehensive article about quantum computing")
result_holder[0] = result
# Start agent in background
thread = threading.Thread(target=run_agent)
thread.start()
# Send steering messages while it's running
time.sleep(0.5) # Let execution start
agent.steer("Make it accessible to beginners")
time.sleep(1.0)
agent.steer("Include practical applications", priority=15)
time.sleep(1.5)
agent.steer("Keep it under 300 words total", priority=20)
# Wait for completion
thread.join(timeout=60)
if result_holder[0]:
print("\n📝 Final Result:")
print(result_holder[0])
else:
print("⚠️ Agent execution timed out")
async def async_message_steering():
"""Async example showing message steering with async execution."""
print("\n⚡ Async Message Steering Example")
print("=" * 50)
agent = Agent(
name="analyst",
instructions="You are a data analyst",
message_steering=True,
llm="gpt-4o-mini"
)
# Start async task
async def run_analysis():
return await agent.astart("Analyze the impact of AI on job markets")
# Create task
task = asyncio.create_task(run_analysis())
# Send steering while running
await asyncio.sleep(0.3)
agent.steer("Focus on positive opportunities")
await asyncio.sleep(0.7)
agent.steer("Include specific examples", priority=12)
# Wait for result
result = await task
print("\n📝 Analysis Result:")
print(result)
def cli_yaml_examples():
"""Show CLI and YAML usage examples."""
print("\n💻 CLI & YAML Examples")
print("=" * 50)
print("🔧 CLI Usage:")
print(" praisonai --message-steering 'Your task here'")
print(" # Then in another terminal:")
print(" # agent.steer('Additional guidance')")
print("\n📄 YAML Configuration:")
yaml_example = """
# agents.yaml
framework: praisonai
roles:
researcher:
role: Research Assistant
goal: Conduct thorough research
backstory: Expert researcher with steering support
message_steering: true # Enable steering for this agent
tools:
- internet_search
- write_file
tasks:
research_task:
description: Research AI trends and write report
expected_output: Comprehensive research report
"""
print(yaml_example.strip())
if __name__ == "__main__":
# Run examples
basic_message_steering()
threaded_message_steering()
# Run async example
asyncio.run(async_message_steering())
# Show CLI/YAML usage
cli_yaml_examples()
print("\n✨ Message steering examples completed!")
print("📚 See docs for more advanced patterns and priority levels")
@@ -30,7 +30,11 @@ config = {
agent = Agent(
name="Knowledge Agent",
instructions="You answer questions based on the provided knowledge.",
knowledge={**config, "sources": ["kag-research-paper.pdf"]},
knowledge={
"sources": ["kag-research-paper.pdf"],
"vector_store": config["vector_store"],
"embedder": config["embedder"],
},
memory={"user_id": "user1"},
llm="deepseek-r1"
)
@@ -31,10 +31,9 @@ llm_config = {
agent = Agent(
instructions="You are a helpful Assistant specialized in scientific explanations. "
"Provide clear, accurate, and engaging responses.",
llm=llm_config, # Pass the detailed configuration
# Enable detailed output
markdown=True, # Format responses in markdown
reflection=True # Enable self-reflection
llm=llm_config,
output={"markdown": True},
reflection=True,
)
# Test the agent
@@ -28,8 +28,8 @@ if not os.environ.get("OPENAI_API_KEY"):
def main():
# MongoDB memory configuration
mongodb_memory_config = {
"provider": "mongodb",
"config": {
"provider": "mongodb",
"connection_string": "mongodb://localhost:27017/", # Replace with your MongoDB connection string
"database": "praisonai_memory",
"use_vector_search": True, # Enable Atlas Vector Search (requires MongoDB Atlas)
@@ -4,7 +4,7 @@ Example 3: Save Agent Output Using Workflow output_file
Workflow step output is automatically saved with variable substitution.
"""
from praisonaiagents import AgentFlow
from praisonaiagents import Agent, AgentFlow, Task
# Define workflow with output_file
workflow_config = {
@@ -33,9 +33,30 @@ workflow_config = {
]
}
# Run workflow
workflow = AgentFlow(config=workflow_config)
result = workflow.run()
# Build agent and workflow from config dict
writer_cfg = workflow_config["agents"]["writer"]
writer = Agent(
name="writer",
role=writer_cfg["role"],
goal=writer_cfg["goal"],
llm=writer_cfg["llm"],
)
step_cfg = workflow_config["steps"][0]
workflow = AgentFlow(
name=workflow_config["metadata"]["name"],
variables=workflow_config["variables"],
steps=[
Task(
name="write_tutorial",
description=step_cfg["action"],
expected_output=step_cfg["expected_output"],
output_file=step_cfg["output_file"],
agent=writer,
)
],
)
result = workflow.start()
print("✅ Workflow completed!")
print("Output saved to: generated/tutorial.md")
@@ -28,7 +28,7 @@ def example_basic_persistence():
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
memory={"session_id": session_id},
output="minimal",
)
@@ -59,7 +59,7 @@ def example_session_restoration():
agent1 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
memory={"session_id": session_id},
output="minimal",
)
@@ -72,7 +72,7 @@ def example_session_restoration():
agent2 = Agent(
name="MemoryBot",
instructions="You are a helpful assistant. Be very brief.",
session_id=session_id,
memory={"session_id": session_id},
output="minimal",
)
@@ -69,9 +69,10 @@ agents_system = AgentTeam(
memory=True, output="verbose"
)
print("Starting state management demonstration...")
result = agents_system.start()
if __name__ == "__main__":
print("Starting state management demonstration...")
result = agents_system.start()
print(f"\nState Management Result: {result[:200]}...")
print("\n✅ State management patterns complete!")
print("Agent demonstrated persistent state tracking across multiple tasks.")
print(f"\nState Management Result: {str(result)[:200]}...")
print("\n✅ State management patterns complete!")
print("Agent demonstrated persistent state tracking across multiple tasks.")
@@ -179,7 +179,7 @@ def main():
context = memory.build_context_for_task(
task_descr="Explain AI alignment challenges",
memory={"user_id": "quality_demo_user"},
user_id="quality_demo_user",
max_items=3
)
+1 -1
View File
@@ -15,7 +15,7 @@ def main():
# Create a session with persistent storage
session = Session(
session_id="demo_chat_001",
memory={"user_id": "demo_user"},
user_id="demo_user",
)
print(f"📝 Created session: {session}")
@@ -1,284 +1,55 @@
"""
Production Telemetry Example
This example demonstrates comprehensive telemetry and monitoring setup for
production AI agent systems, including metrics collection, performance tracking,
and observability.
Features demonstrated:
- OpenTelemetry integration for distributed tracing
- Custom metrics collection and monitoring
- Performance tracking and analytics
- Error tracking and alerting
- Usage analytics and reporting
Demonstrates compatible telemetry setup with `enable_telemetry()` and
`TelemetryCollector` patterns.
"""
from contextlib import nullcontext
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.tools import duckduckgo
from praisonaiagents.telemetry import enable_telemetry
import time
import random
from datetime import datetime
from praisonaiagents.telemetry import enable_telemetry, TelemetryCollector
# Initialize telemetry for production monitoring
print("="*80)
print("PRODUCTION TELEMETRY SETUP")
print("="*80)
# Configure telemetry (in production, you'd use actual telemetry backends)
telemetry = enable_telemetry(
backend="opentelemetry",
config={
"service_name": "praisonai_production_agents",
"service_version": "1.0.0",
"environment": "production",
"export_console": True, # For demo purposes
"export_file": True, # Export to file for analysis
"sample_rate": 1.0, # 100% sampling for demo
"attributes": {
"deployment": "cloud",
"region": "us-east-1",
"cluster": "main"
}
}
)
def main() -> None:
print("=" * 80)
print("PRODUCTION TELEMETRY SETUP")
print("=" * 80)
# Create agents for production workflow with telemetry
customer_service_agent = Agent(
name="CustomerServiceAgent",
role="Customer Service Representative",
goal="Provide excellent customer service with fast response times",
backstory="You are a customer service representative focused on resolving customer issues quickly and effectively.",
tools=[duckduckgo],
instructions="Provide helpful, accurate customer service responses. Research solutions when needed."
)
# New API: no args
enable_telemetry()
collector = TelemetryCollector()
technical_support_agent = Agent(
name="TechnicalSupportAgent",
role="Technical Support Specialist",
goal="Resolve technical issues with detailed solutions",
backstory="You are a technical support specialist who provides detailed technical solutions and troubleshooting.",
instructions="Provide detailed technical solutions with step-by-step instructions."
)
customer_service_agent = Agent(
name="CustomerServiceAgent",
role="Customer Service Representative",
goal="Provide excellent customer service with fast response times",
backstory="Customer service expert focused on quick issue resolution.",
tools=[duckduckgo],
instructions="Provide accurate customer-service responses.",
)
quality_assurance_agent = Agent(
name="QualityAssuranceAgent",
role="Quality Assurance Specialist",
goal="Ensure all customer interactions meet quality standards",
backstory="You review and assess the quality of customer service interactions to ensure high standards.",
instructions="Review customer service interactions and provide quality scores with detailed feedback."
)
# Production workflow simulation with telemetry tracking
def simulate_customer_requests():
"""Simulate various customer requests for telemetry demonstration"""
customer_requests = [
{
"type": "billing_inquiry",
"complexity": "low",
"description": "Customer asking about their monthly bill charges",
"expected_duration": 30
},
{
"type": "technical_issue",
"complexity": "high",
"description": "Customer experiencing connectivity issues with the service",
"expected_duration": 120
},
{
"type": "account_setup",
"complexity": "medium",
"description": "New customer needs help setting up their account",
"expected_duration": 60
},
{
"type": "feature_question",
"complexity": "low",
"description": "Customer asking about product features and capabilities",
"expected_duration": 45
}
requests = [
"Customer asking about monthly bill charges",
"Customer experiencing connectivity issues",
]
return customer_requests
# Execute production workflow with comprehensive telemetry
print("Starting production workflow with telemetry tracking...")
for idx, description in enumerate(requests, start=1):
trace_ctx = getattr(collector, "trace", None)
trace_cm = trace_ctx(f"customer_request_{idx}") if callable(trace_ctx) else nullcontext()
with trace_cm:
task = Task(
name=f"customer_request_{idx}",
description=description,
expected_output="Professional customer service response with solution",
agent=customer_service_agent,
)
agents = AgentTeam(agents=[customer_service_agent], tasks=[task], output="minimal")
result = agents.start()
print(f"✅ Request {idx} completed, response length={len(str(result))}")
customer_requests = simulate_customer_requests()
print("\nTelemetry demonstration completed.")
for i, request in enumerate(customer_requests):
print(f"\n{'='*60}")
print(f"PROCESSING CUSTOMER REQUEST {i+1}: {request['type'].upper()}")
print(f"Complexity: {request['complexity']} | Expected Duration: {request['expected_duration']}s")
print(f"{'='*60}")
# Start telemetry trace for this customer interaction
with telemetry.trace_operation(f"customer_request_{request['type']}") as trace:
# Add custom attributes to the trace
trace.set_attributes({
"request.type": request['type'],
"request.complexity": request['complexity'],
"request.expected_duration": request['expected_duration'],
"customer.id": f"customer_{random.randint(1000, 9999)}",
"agent.assigned": "CustomerServiceAgent"
})
start_time = time.time()
# Create customer service task with telemetry
customer_task = Task(
name=f"customer_request_{i+1}",
description=request['description'],
expected_output="Professional customer service response with solution",
agent=customer_service_agent
)
# Execute with telemetry tracking
try:
# Customer service response
with telemetry.trace_agent_execution("CustomerServiceAgent") as agent_trace:
agents = AgentTeam(
agents=[customer_service_agent],
tasks=[customer_task],
output="minimal" # Reduce verbosity for telemetry demo
)
cs_result = agents.start()
agent_trace.set_attributes({
"agent.response_length": len(str(cs_result)),
"agent.tools_used": len(customer_service_agent.tools) if customer_service_agent.tools else 0
})
# Technical support (if needed for high complexity)
if request['complexity'] == 'high':
with telemetry.trace_agent_execution("TechnicalSupportAgent") as tech_trace:
tech_task = Task(
name=f"technical_support_{i+1}",
description=f"Provide detailed technical support for: {request['description']}",
expected_output="Detailed technical solution with troubleshooting steps",
agent=technical_support_agent,
context=[customer_task]
)
tech_agents = AgentTeam(
agents=[technical_support_agent],
tasks=[tech_task], output="minimal"
)
tech_result = tech_agents.start()
tech_trace.set_attributes({
"agent.response_length": len(str(tech_result)),
"agent.escalation": True
})
# Quality assurance check
with telemetry.trace_agent_execution("QualityAssuranceAgent") as qa_trace:
qa_task = Task(
name=f"quality_check_{i+1}",
description=f"Review the quality of customer service provided for: {request['description']}",
expected_output="Quality assessment score and feedback",
agent=quality_assurance_agent
)
qa_agents = AgentTeam(
agents=[quality_assurance_agent],
tasks=[qa_task], output="minimal"
)
qa_result = qa_agents.start()
# Extract quality score (simulated)
quality_score = random.uniform(0.8, 1.0) # Simulated score
qa_trace.set_attributes({
"qa.score": quality_score,
"qa.passed": quality_score >= 0.85
})
# Calculate performance metrics
end_time = time.time()
duration = end_time - start_time
# Record custom metrics
telemetry.record_metric("request_duration", duration, {
"request_type": request['type'],
"complexity": request['complexity']
})
telemetry.record_metric("request_success", 1, {
"request_type": request['type']
})
# Set final trace attributes
trace.set_attributes({
"request.duration_seconds": duration,
"request.status": "completed",
"request.quality_score": quality_score,
"request.escalated": request['complexity'] == 'high'
})
print(f"✅ Request completed successfully in {duration:.2f}s (Quality: {quality_score:.2f})")
except Exception as e:
# Record error metrics
telemetry.record_metric("request_error", 1, {
"request_type": request['type'],
"error_type": type(e).__name__
})
trace.set_attributes({
"request.status": "error",
"request.error": str(e)
})
print(f"❌ Request failed: {str(e)}")
# Add some delay between requests
time.sleep(2)
# Generate telemetry summary
print(f"\n{'='*80}")
print("TELEMETRY SUMMARY AND ANALYTICS")
print(f"{'='*80}")
# Simulated telemetry analytics (in production, this would come from your telemetry backend)
print("📊 Performance Metrics:")
print("- Total Requests Processed: 4")
print("- Average Response Time: 3.2s")
print("- Success Rate: 100%")
print("- Quality Score Average: 0.91")
print("- High Complexity Requests: 25%")
print("\n📈 Usage Analytics:")
print("- Most Common Request Type: technical_issue")
print("- Peak Performance Window: All requests within SLA")
print("- Agent Utilization: CustomerService (100%), TechnicalSupport (25%), QA (100%)")
print("\n🔍 Observability Features Demonstrated:")
print("- Distributed tracing across agent interactions")
print("- Custom metrics for business KPIs")
print("- Performance monitoring and SLA tracking")
print("- Error tracking and alerting")
print("- Quality assurance metrics")
print("- User journey tracking")
print("- Resource utilization monitoring")
print("\n⚙️ Production Monitoring Setup:")
print("- OpenTelemetry integration for standardized observability")
print("- Custom metrics for business-specific KPIs")
print("- Trace correlation across multi-agent workflows")
print("- Performance benchmarking against expected durations")
print("- Quality scoring and automated validation")
print("- Error categorization and alerting")
print(f"\n{'='*80}")
print("TELEMETRY DEMONSTRATION COMPLETED")
print(f"{'='*80}")
print("This example demonstrated comprehensive production telemetry including:")
print("- Request tracing and performance monitoring")
print("- Custom business metrics collection")
print("- Quality assurance tracking")
print("- Error handling and alerting")
print("- Multi-agent workflow observability")
print("- Production-ready monitoring patterns")
if __name__ == "__main__":
main()
@@ -14,7 +14,7 @@ def main():
goal="Find information about a topic",
backstory="You are an expert researcher who finds comprehensive information.",
llm="gpt-4o-mini", # Use a cost-effective model for examples
metrics=True # 🎯 SIMPLIFIED: Just add this parameter!
output={"metrics": True} # Token metrics via consolidated output config
)
# Create a simple task
@@ -14,7 +14,7 @@ def main():
goal="Analyze data and provide insights",
backstory="You are an experienced data analyst with expertise in market research.",
llm="gpt-4o-mini",
metrics=True # 🎯 Enable metrics tracking
output={"metrics": True}
)
writer_agent = Agent(
@@ -23,7 +23,7 @@ def main():
goal="Create well-structured content",
backstory="You are a skilled technical writer who creates clear, engaging content.",
llm="gpt-4o-mini",
metrics=True # 🎯 Enable metrics tracking
output={"metrics": True}
)
# Create multiple tasks
@@ -14,7 +14,7 @@ def main():
goal="Conduct comprehensive market research",
backstory="You are a seasoned market researcher with 10+ years of experience.",
llm="gpt-4o-mini",
metrics=True # 🎯 Enable automatic metrics tracking
output={"metrics": True}
)
strategy_agent = Agent(
@@ -23,7 +23,7 @@ def main():
goal="Develop actionable business strategies",
backstory="You are a strategic consultant who translates research into business plans.",
llm="gpt-4o-mini",
metrics=True # 🎯 Enable automatic metrics tracking
output={"metrics": True}
)
# Create tasks that will generate substantial token usage
@@ -22,7 +22,8 @@ rag_agent = Agent(
You can use the internet_search function to search the web when needed.""",
llm="gpt-4o",
markdown=True)
output={"markdown": True},
)
if __name__ == "__main__":
print("🤖 Thai Recipe RAG Agent is ready!")
@@ -5,11 +5,17 @@ from dotenv import load_dotenv
load_dotenv()
os.environ['SERPER_API_KEY'] = os.getenv('SERPER_API_KEY')
serper_api_key = os.getenv("SERPER_API_KEY")
if serper_api_key is not None:
os.environ["SERPER_API_KEY"] = serper_api_key
search = GoogleSerperAPIWrapper()
data_agent = Agent(instructions="Suggest me top 5 most visited websites for Dosa Recipe", tools=[search])
editor_agent = Agent(instructions="List out the websites with their url and a short description")
agents = AgentTeam(agents=[data_agent, editor_agent])
agents.start()
if __name__ == "__main__":
if not serper_api_key:
print("SERPER_API_KEY is not set. Skipping Google Serper example.")
else:
search = GoogleSerperAPIWrapper()
data_agent = Agent(instructions="Suggest me top 5 most visited websites for Dosa Recipe", tools=[search])
editor_agent = Agent(instructions="List out the websites with their url and a short description")
agents = AgentTeam(agents=[data_agent, editor_agent])
agents.start()
@@ -2,6 +2,7 @@ from praisonaiagents import Agent, Task, AgentTeam
from pydantic import BaseModel
from typing import List, Dict
from gitingest import ingest
import sys
class CodeMetrics(BaseModel):
category: str
@@ -60,7 +61,10 @@ def analyze_code(code_source: str) -> CodeAnalysisReport:
Analyze code from directory path or GitHub URL
"""
# Ingest code content
summary, tree, content = ingest(code_source)
try:
summary, tree, content = ingest(code_source)
except Exception as exc:
raise RuntimeError(f"Failed to ingest code source: {exc}") from exc
# Concatenate context into structured format
context_text = f"""
@@ -86,10 +90,22 @@ def analyze_code(code_source: str) -> CodeAnalysisReport:
tasks=[code_analysis_task]
)
return agents.start(context_text)
result = agents.start(context_text)
if result is None:
raise RuntimeError("No analysis result returned by AgentTeam.start()")
return result
if __name__ == "__main__":
def main() -> None:
# Example usage
code_source = "https://github.com/openai/openai-python/tree/main/src/openai/cli/_api/chat" # GitHub URL or local directory
result = analyze_code(code_source)
print(result)
try:
result = analyze_code(code_source)
except RuntimeError as exc:
print(f"Code analysis failed: {exc}")
sys.exit(1)
print(result)
if __name__ == "__main__":
main()
@@ -77,25 +77,44 @@ agents = AgentTeam(
process="sequential", output="verbose"
)
def _extract_pydantic_result(result_obj):
"""Extract pydantic payload from AgentTeam.start() output shapes."""
if hasattr(result_obj, "pydantic"):
return result_obj.pydantic
if isinstance(result_obj, dict):
task_results = result_obj.get("task_results")
if isinstance(task_results, dict):
values = list(task_results.values())
if values and hasattr(values[-1], "pydantic"):
return values[-1].pydantic
if isinstance(task_results, list) and task_results and hasattr(task_results[-1], "pydantic"):
return task_results[-1].pydantic
return None
# Start the analysis
result = agents.start()
pydantic_result = _extract_pydantic_result(result)
if pydantic_result is None:
raise ValueError("Could not extract structured CVAnalysisReport from workflow result")
# Access the structured results
print("\nCV Analysis Results:")
print(f"\nOverall Score: {result.pydantic.overall_score}/100")
print(f"\nOverall Score: {pydantic_result.overall_score}/100")
print("\nKey Strengths:")
for strength in result.pydantic.key_strengths:
for strength in pydantic_result.key_strengths:
print(f"- {strength}")
print("\nSkill Analysis:")
print("\nTechnical Skills:")
for skill in result.pydantic.skill_analysis.technical_skills:
for skill in pydantic_result.skill_analysis.technical_skills:
print(f"- {skill}")
print("\nExperience Highlights:")
for achievement in result.pydantic.experience_analysis.key_achievements:
for achievement in pydantic_result.experience_analysis.key_achievements:
print(f"- {achievement}")
print("\nRecommendations:")
for recommendation in result.pydantic.recommendations:
for recommendation in pydantic_result.recommendations:
print(f"- {recommendation}")
@@ -100,6 +100,8 @@ def evaluate_project(video_path: str) -> ProjectEvaluation:
evaluation_data = response
print(f"Debug - Parsed evaluation_data: {evaluation_data}")
if not evaluation_data or not isinstance(evaluation_data, dict):
raise ValueError("No structured evaluation data returned by agent")
# Create and return ProjectEvaluation instance
return ProjectEvaluation(
+9 -3
View File
@@ -1,5 +1,11 @@
from praisonaiagents import VideoAgent
import os
agent = VideoAgent(llm="azure/sora-2")
video = agent.generate("Mountain landscape")
print(video.id)
if __name__ == "__main__":
if not os.getenv("AZURE_API_BASE"):
print("AZURE_API_BASE is not configured. Skipping Azure video example.")
else:
agent = VideoAgent(llm="azure/sora-2")
video = agent.generate("Mountain landscape")
print(video.id)
+1 -1
View File
@@ -5,7 +5,7 @@ Demonstrates using Agent instances directly as workflow steps.
Each agent processes the input and passes output to the next agent.
"""
from praisonaiagents import Agent, Workflow
from praisonaiagents import Agent, AgentFlow
# Create agents with specific roles
researcher = Agent(
+5 -2
View File
@@ -6,6 +6,7 @@ with Agents for workflow monitoring and logging.
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.config.feature_configs import MultiAgentHooksConfig
# Create an agent
researcher = Agent(
@@ -47,8 +48,10 @@ if __name__ == "__main__":
agents=[researcher],
tasks=[task1, task2],
process="workflow",
on_task_start=on_start,
on_task_complete=on_complete,
hooks=MultiAgentHooksConfig(
on_task_start=on_start,
on_task_complete=on_complete,
),
variables={"global_var": "shared_value"} # Global variables
)
@@ -54,11 +54,13 @@ workflow = AgentFlow(
analyzer, # Always runs
Task(
name="tech_review",
description="Review technical aspects when technical keywords are detected.",
agent=tech_reviewer,
should_run=is_technical # Only runs for technical content
),
Task(
name="creative_enhance",
description="Enhance narrative quality when creative keywords are detected.",
agent=creative_enhancer,
should_run=needs_creative # Only runs for creative content
),
@@ -53,7 +53,7 @@ workflow = AgentFlow(
# Step 4: Function - formats output
format_output
],
default_llm="gpt-4o-mini" # Used for action-based steps
llm="gpt-4o-mini", # Used for action-based steps
)
if __name__ == "__main__":
@@ -42,6 +42,7 @@ def main():
# Create tasks with robustness features
research_task = Task(
name="research",
description="Research the latest trends in AI agents for 2025",
expected_output="A summary of key AI agent trends",
agent=researcher,
@@ -51,6 +52,7 @@ def main():
# Optional enrichment step - workflow continues even if this fails
enrich_task = Task(
name="enrich",
description="Enrich the research with real-world examples",
expected_output="Research with added examples",
agent=enricher,
@@ -60,6 +62,7 @@ def main():
)
write_task = Task(
name="write",
description="Write a brief summary based on the research",
expected_output="A well-written summary paragraph",
agent=writer,
+9 -7
View File
@@ -51,13 +51,15 @@ print("=" * 60)
agent = Agent(
name="Knowledge Agent",
instructions="You are a helpful assistant that answers questions based on the provided knowledge. Be concise.",
knowledge=[".praison/test_docs/overview.txt"],
retrieval_config={
"policy": "always", # Always retrieve for this demo
"top_k": 3, # Get top 3 chunks
"citations": True, # Include citations
"max_context_tokens": 2000,
}
knowledge={
"sources": [".praison/test_docs/overview.txt"],
"config": {
"policy": "always",
"top_k": 3,
"citations": True,
"max_context_tokens": 2000,
},
},
)
# Test 1: Basic chat with retrieval
+1 -1
View File
@@ -60,7 +60,7 @@ print("=" * 50)
agent_with_discovery = Agent(
name="Multi-Skill Agent",
instructions="You are a versatile assistant with multiple skills.",
skills_dirs=["./"], # Scan current directory for skill subdirectories
skills=["./"], # Scan current directory for skill subdirectories
)
if agent_with_discovery.skill_manager:
+1 -1
View File
@@ -190,7 +190,7 @@ def example_run_history_with_backend():
# Use with RunHistory
history = RunHistory(backend=backend)
runs = history.list()
runs = history.list_runs()
print(f"RunHistory created with SQLite backend, {len(runs)} runs")
print("Recipe runs will be stored in database instead of files")
+1 -1
View File
@@ -257,7 +257,7 @@ if __name__ == "__main__":
# Check dependencies
try:
import praisonaiagents
print(f"✅ PraisonAI version: {praisonaiagents.__version__}")
print(f"✅ PraisonAI version: {getattr(praisonaiagents, '__version__', None)}")
except ImportError:
print("❌ PraisonAI not installed: pip install praisonaiagents")
sys.exit(1)
+1 -1
View File
@@ -12,7 +12,7 @@ assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY must be set"
# Enable web search and fetch
agent = Agent(
instructions="You are a research assistant with web access.",
web=True,
web="search_only",
)
# Disable web (default)
+3 -11
View File
@@ -4,7 +4,7 @@ WebConfig Example
Demonstrates using WebConfig for fine-grained control.
"""
import os
from praisonaiagents import Agent, WebConfig
from praisonaiagents import Agent
# Ensure API key is set from environment
assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY must be set"
@@ -12,21 +12,13 @@ assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY must be set"
# Custom web configuration
agent = Agent(
instructions="You are a research assistant.",
web=WebConfig(
search=True,
fetch=True,
search_provider="duckduckgo",
max_results=5,
),
web="search_only",
)
# Search only (no fetch)
agent_search_only = Agent(
instructions="You are a research assistant.",
web=WebConfig(
search=True,
fetch=False,
),
web="search_only",
)
if __name__ == "__main__":
@@ -0,0 +1,47 @@
framework: praisonai
topic: AI Research with Message Steering
roles:
researcher:
role: AI Research Analyst
goal: Conduct comprehensive AI research with real-time guidance
backstory: You are an expert AI researcher who can receive and incorporate real-time guidance during your work
message_steering: true # Enable real-time message steering
tools:
- internet_search
- write_file
tasks:
research_task:
description: |
Research the latest developments in AI and write a comprehensive report.
You may receive additional guidance during execution via message steering.
expected_output: |
A well-structured research report covering recent AI developments,
incorporating any real-time guidance received during execution.
writer:
role: Technical Writer
goal: Write clear, engaging content based on research
backstory: You are a skilled technical writer who can adapt content based on real-time feedback
message_steering: true # Enable steering for dynamic content adjustment
tools:
- read_file
- write_file
tasks:
writing_task:
description: |
Take the research findings and create an accessible article for a general audience.
Adjust style and focus based on any steering messages received.
expected_output: |
A polished article that transforms technical research into accessible content,
refined based on real-time steering guidance.
dependencies:
- writing_task: [research_task]
# Usage Instructions:
# 1. Run: praisonai --agent-file message_steering_example.yaml
# 2. While running, use steering API to send guidance:
# agent.steer("Focus more on practical applications")
# agent.steer("Keep it under 500 words", priority=15)
# agent.steer("Add more examples", priority=10)
+1 -1
View File
@@ -93,7 +93,7 @@ def example_3_execute_yaml_directly():
result = manager.execute_yaml(
yaml_file,
input_data="The future of AI",
output="verbose"
verbose=True,
)
print(f"Status: {result['status']}")
@@ -191,6 +191,7 @@ _LAZY_IMPORTS = {
'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'),
'HandoffToolPolicy': ('praisonaiagents.agent.handoff', 'HandoffToolPolicy'),
'HandoffResult': ('praisonaiagents.agent.handoff', 'HandoffResult'),
'HandoffInputData': ('praisonaiagents.agent.handoff', 'HandoffInputData'),
'ContextPolicy': ('praisonaiagents.agent.handoff', 'ContextPolicy'),
@@ -166,6 +166,21 @@ def __getattr__(name):
_lazy_cache[name] = value
return value
# Message Steering - lightweight protocols and implementations
_steering_names = {
'MessageSteeringProtocol', 'AgentSteeringProtocol', 'SteeringPriority',
'SteeringMessage', 'MessageSteering', 'SteeringMixin'
}
if name in _steering_names:
if name in {'MessageSteeringProtocol', 'AgentSteeringProtocol', 'SteeringPriority', 'SteeringMessage'}:
from . import protocols as _protocols_module
value = getattr(_protocols_module, name)
else:
from . import message_steering as _steering_module
value = getattr(_steering_module, name)
_lazy_cache[name] = value
return value
# Decomposed mixins - for advanced use cases
if name == 'ToolExecutionMixin':
from .tool_execution import ToolExecutionMixin
@@ -256,6 +271,13 @@ __all__ = [
'MemoryAwareAgentProtocol',
'FullAgentProtocol',
'ContextEngineerProtocol',
# Message Steering
'MessageSteeringProtocol',
'AgentSteeringProtocol',
'SteeringPriority',
'SteeringMessage',
'MessageSteering',
'SteeringMixin',
# Decomposed mixins (for advanced use cases)
'ToolExecutionMixin',
'ChatHandlerMixin',
@@ -22,6 +22,7 @@ from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState
from .unified_execution_mixin import UnifiedExecutionMixin
from .sandbox_mixin import SandboxMixin
from .message_steering import SteeringMixin
# Module-level logger for thread safety errors and debugging
logger = get_logger(__name__)
@@ -242,7 +243,7 @@ def _get_default_server_registry() -> ServerRegistry:
if TYPE_CHECKING:
from ..approval.protocols import ApprovalConfig, ApprovalProtocol
from ..config.feature_configs import LearnConfig, MemoryConfig
from ..config.feature_configs import LearnConfig, MemoryConfig, ToolConfig
from ..context.models import ContextConfig
from ..context.manager import ContextManager
from ..knowledge.knowledge import Knowledge
@@ -252,11 +253,12 @@ if TYPE_CHECKING:
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
from ..eval.results import EvaluationLoopResult
from ..tools.retry import RetryPolicy
# Import structured error from central errors module
from ..errors import BudgetExceededError
class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
_agent_counter_lock = threading.Lock()
@@ -551,13 +553,13 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
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
parallel_tool_calls: bool = False, # Gap 2: Enable parallel execution of batched LLM tool calls
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
):
"""Initialize an Agent instance.
@@ -646,10 +648,10 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
- LearnConfig: Custom configuration
Learning is a first-class citizen, peer to memory. It captures patterns,
preferences, and insights from interactions to improve future responses.
parallel_tool_calls: Enable parallel execution of batched LLM tool calls (default False).
When True and LLM returns multiple tool calls in a single response, they execute
concurrently instead of sequentially. Provides ~3x speedup for I/O-bound tools.
Maintains backward compatibility with False default.
tool_config: Tool execution configuration. Accepts:
- bool: True enables defaults, False disables
- ToolConfig: Custom configuration for timeout, retry policy, parallel execution
Consolidates tool timeout, retry policy, and parallel execution settings.
backend: External managed agent backend for hybrid execution. Accepts:
- ManagedAgentIntegration: External managed agent service
- None: Use local execution (default)
@@ -689,6 +691,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
- auto_save → memory=MemoryConfig(auto_save=)
- rate_limiter → execution=ExecutionConfig(rate_limiter=)
- verification_hooks → autonomy=AutonomyConfig(verification_hooks=)
- Use tool_config=ToolConfig(...) for tool execution configuration
"""
# Add check at start if memory is requested
if memory is not None:
@@ -799,8 +802,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
alternative="use 'execution=ExecutionConfig(rate_limiter=obj)' instead",
stacklevel=3
)
# Note: parallel_tool_calls is NOT deprecated - it's a new Gap 2 feature
# Both direct parameter and ExecutionConfig.parallel_tool_calls are supported
# Note: parallel_tool_calls moved to tool_config pattern
if verification_hooks is not None:
warn_deprecated_param(
"verification_hooks",
@@ -976,8 +978,8 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
allow_code_execution = True
if _exec_config.code_mode != "safe":
code_execution_mode = _exec_config.code_mode
# Get parallel_tool_calls from ExecutionConfig, fall back to parameter
parallel_tool_calls = getattr(_exec_config, 'parallel_tool_calls', parallel_tool_calls)
# Get parallel_tool_calls from ExecutionConfig
parallel_tool_calls = getattr(_exec_config, 'parallel_tool_calls', False)
# Budget guard extraction
_max_budget = getattr(_exec_config, 'max_budget', None)
_on_budget_exceeded = getattr(_exec_config, 'on_budget_exceeded', 'stop') or 'stop'
@@ -985,7 +987,8 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
max_iter, max_rpm, max_execution_time, max_retry_limit = 20, None, None, 2
_max_budget = None
_on_budget_exceeded = 'stop'
# Keep parallel_tool_calls parameter value when no ExecutionConfig provided
# Default to False when no ExecutionConfig provided
parallel_tool_calls = False
# (already set from parameter, no need to override)
# ─────────────────────────────────────────────────────────────────────
@@ -1512,8 +1515,14 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
self.self_reflect = True if self_reflect is None else self_reflect
self.instructions = instructions
# Resolve tool_config for tool execution settings
_tool_config = Agent._resolve_tool_config(
tool_config=tool_config
)
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
self.parallel_tool_calls = parallel_tool_calls
self.parallel_tool_calls = _tool_config.parallel if _tool_config else parallel_tool_calls
# G2: Store interrupt controller for cooperative cancellation
self.interrupt_controller = interrupt_controller
# Check for model name in environment variable if not provided
@@ -1729,6 +1738,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
self.max_execution_time = max_execution_time
self._memory_instance = None
self._init_memory(memory, user_id)
self._init_message_steering(message_steering)
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
@@ -1911,8 +1921,14 @@ Your Goal: {self.goal}
self._pending_approvals = {}
self._approvals_lock = asyncio.Lock()
# Store the resolved tool_config for cloning (resolved earlier in __init__)
self._tool_config = _tool_config
# P8/G11: Tool timeout - prevent slow tools from blocking
self._tool_timeout = tool_timeout
self._tool_timeout = _tool_config.timeout if _tool_config else None
# Store tool retry policy for tool execution with exponential backoff
self._tool_retry_policy = _tool_config.retry_policy if _tool_config else None
# Cache for system prompts and formatted tools with eager thread-safe lock
# Use OrderedDict for LRU behavior
@@ -2028,6 +2044,19 @@ Your Goal: {self.goal}
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
@staticmethod
def _resolve_tool_config(tool_config):
"""Resolve tool_config parameter with backward compatibility."""
# Import here to avoid circular imports
from ..config.feature_configs import ToolConfig, resolve_tools
# Handle the new consolidated parameter
if tool_config is not None:
resolved_config = resolve_tools(tool_config)
return resolved_config
return None
def __deepcopy__(self, memo: dict) -> "Agent":
"""Custom deepcopy that creates fresh threading primitives.
@@ -2104,9 +2133,8 @@ Your Goal: {self.goal}
'learn': getattr(self, '_learn_config', None),
'tool_search': getattr(self, '_tool_search_config', None),
# Tool configuration
'tool_timeout': getattr(self, '_tool_timeout', None),
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
# Tool configuration - use consolidated config when available
'tool_config': getattr(self, '_tool_config', None),
# CLI backend
'cli_backend': getattr(self, '_cli_backend', None),
@@ -239,7 +239,7 @@ Your Goal: {self.goal}"""
try:
from ..llm.model_capabilities import supports_structured_outputs
except ImportError:
return False # Module genuinely not available — acceptable
return False # Module genuinely not available - acceptable
try:
return supports_structured_outputs(self.llm)
@@ -343,13 +343,19 @@ Your Goal: {self.goal}"""
- Tool Search progressive disclosure (if enabled)
Args:
tools: List of tools in various formats or None to use self.tools
tools: List of tools in various formats or None to use self.tools.
Note: [] (empty list) means explicitly no tools (security boundary),
None means inherit from self.tools
Returns:
List of formatted tools or empty list
"""
# Security fix: Distinguish None (inherit) vs [] (explicit deny)
if tools is None:
tools = self.tools
elif isinstance(tools, list) and len(tools) == 0:
# Explicit empty list - return immediately to enforce boundary
return []
if not tools:
return []
@@ -900,7 +906,7 @@ Your Goal: {self.goal}"""
temperature=temperature
)
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
# C7 — honour any BEFORE_LLM hook that mutated the message stream
# C7 - honour any BEFORE_LLM hook that mutated the message stream
# (e.g. PII redactor). The runner applies modified_input in-place on
# before_llm_input.messages; adopt that value for the actual LLM call.
messages = before_llm_input.messages
@@ -1058,7 +1064,6 @@ Your Goal: {self.goal}"""
# Use structured error classification for all error types (replaces legacy heuristic checks)
from ..llm.error_classifier import classify_llm_error
from ..llm.retry_utils import jittered_backoff
import time
model_name = self.llm if isinstance(self.llm, str) else "unknown"
session_id = getattr(self, '_session_id', 'unknown')
@@ -1931,6 +1936,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# body before any backend/LLM call.
prompt = self._resolve_skill_invocation(prompt)
# Check for steering messages before processing
if hasattr(self, '_check_steering_messages'):
try:
steering_msg = self._check_steering_messages()
if steering_msg:
# Inject steering message into prompt with clear separator
prompt = f"{prompt}\n\n{steering_msg}"
except Exception as e:
logger.warning(f"Steering check failed, continuing without steering: {e}")
# Check if external managed backend is configured
if hasattr(self, 'backend') and self.backend is not None:
# Extract kwargs for delegation, excluding 'self' and function locals
@@ -1958,7 +1973,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
try:
# C2 — cooperative cancellation: abort early if a pre-set token is given
# C2 - cooperative cancellation: abort early if a pre-set token is given
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
@@ -2089,9 +2104,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self._using_custom_llm:
try:
# Special handling for MCP tools when using provider/model format
# Fix: Handle empty tools list properly - use self.tools if tools is None or empty
if tools is None or (isinstance(tools, list) and len(tools) == 0):
# Security fix: Distinguish None (inherit) vs [] (explicit deny)
if tools is None:
# None means inherit agent's configured tools
tool_param = self.tools
elif isinstance(tools, list) and len(tools) == 0:
# Empty list means explicitly deny all tools (security boundary)
tool_param = []
else:
tool_param = tools
@@ -2198,6 +2217,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
execute_tool_fn=self.execute_tool,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
max_tool_calls_per_turn=getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10),
reasoning_steps=reasoning_steps,
stream=stream
)
@@ -2208,11 +2228,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if effective_tool_choice:
llm_kwargs['tool_choice'] = effective_tool_choice
# C1 — per-call seed overrides llm_instance.seed for determinism
# C1 - per-call seed overrides llm_instance.seed for determinism
if seed is not None:
llm_kwargs['seed'] = seed
# C2 — last-chance cancel check before handing to the LLM
# C2 - last-chance cancel check before handing to the LLM
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
raise InterruptedError(f"Agent chat cancelled: {reason}")
@@ -2323,7 +2343,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
agent_tools=agent_tools
)
response = self._chat_completion(messages, temperature=temperature, tools=tools if tools else None, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
if not response:
# Rollback chat history on response failure
self._truncate_chat_history(chat_history_length)
@@ -2523,6 +2543,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Slash-command invocation: /skill-name [args] renders the skill body.
prompt = self._resolve_skill_invocation(prompt)
# Check for steering messages before processing
if hasattr(self, '_check_steering_messages'):
try:
steering_msg = self._check_steering_messages()
if steering_msg:
# Inject steering message into prompt with clear separator
prompt = f"{prompt}\n\n{steering_msg}"
except Exception as e:
logger.warning(f"Steering check failed, continuing without steering: {e}")
# Emit context trace event (zero overhead when not set)
from ..trace.context_events import get_context_emitter
_trace_emitter = get_context_emitter()
@@ -2543,7 +2573,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
"""Internal async chat implementation (extracted for trace wrapping)."""
# C2 — cooperative cancellation: abort early if a pre-set token is given
# C2 - cooperative cancellation: abort early if a pre-set token is given
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
@@ -2661,7 +2691,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
pass
try:
# C1 — per-call seed forwarding (async path)
# C1 - per-call seed forwarding (async path)
llm_kwargs = {
'prompt': prompt,
'system_prompt': self._build_system_prompt(tools),
@@ -2684,15 +2714,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
'task_id': task_id,
'execute_tool_fn': self.execute_tool_async,
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
'max_tool_calls_per_turn': getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10),
'reasoning_steps': reasoning_steps,
'stream': stream
}
# C1 — per-call seed overrides llm_instance.seed for determinism
# C1 - per-call seed overrides llm_instance.seed for determinism
if seed is not None:
llm_kwargs['seed'] = seed
# C2 — last-chance cancel check before handing to the LLM
# C2 - last-chance cancel check before handing to the LLM
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
reason = getattr(_cancel, 'reason', None) or 'cancelled'
raise InterruptedError(f"Agent chat cancelled: {reason}")
@@ -3424,7 +3455,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_description=kwargs.get('task_description'),
task_id=kwargs.get('task_id'),
execute_tool_fn=self.execute_tool,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False)
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
max_tool_calls_per_turn=getattr(getattr(self, "execution", None), "max_tool_calls_per_turn", 10)
):
response_content += chunk
yield chunk
@@ -9,6 +9,7 @@ import time
import json
import logging
import inspect
import os
import asyncio
import threading
@@ -805,7 +806,25 @@ Write the complete compiled report:"""
# Suppress verbose during animation - we'll display result ourselves
self.verbose = False
current_status[0] = "Sending to LLM..."
result_holder[0] = self.chat(prompt, **kwargs)
# Check for steering messages before starting chat
if hasattr(self, '_check_steering_messages'):
try:
steering_msg = self._check_steering_messages()
if steering_msg:
# Inject steering message into prompt with clear separator
prompt_with_steering = f"{prompt}\n\n{steering_msg}"
current_status[0] = "Processing steering guidance..."
result_holder[0] = self.chat(prompt_with_steering, **kwargs)
else:
result_holder[0] = self.chat(prompt, **kwargs)
except Exception as e:
logger.warning(f"Steering check failed, continuing without steering: {e}")
current_status[0] = "Steering failed, proceeding normally..."
result_holder[0] = self.chat(prompt, **kwargs)
else:
result_holder[0] = self.chat(prompt, **kwargs)
current_status[0] = "Finalizing response..."
except Exception as e:
error_holder[0] = e
@@ -947,7 +966,80 @@ Write the complete compiled report:"""
return await self.achat(prompt, task_name=task_name, task_description=task_description, task_id=task_id)
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Async version of execute_tool"""
"""Async version of execute_tool with retry policy support"""
# Get retry policy (tool-level > agent-level > default)
retry_policy = self._get_tool_retry_policy(function_name)
last_exception = None
# Retry loop with exponential backoff
for attempt in range(retry_policy.max_attempts):
try:
result = await self._execute_tool_async_impl(function_name, arguments, tool_call_id, tools_override)
# Check if result is an error that should be retried
if isinstance(result, dict) and result.get("error"):
# Skip retry for non-retryable errors (approval, permission, etc.)
if (result.get("approval_denied") or
result.get("permission_denied") or
result.get("approval_error") or
result.get("circuit_open")):
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
# Check if we should retry
if not retry_policy.should_retry(error_type, attempt):
return result
# Don't retry on last attempt
if attempt == retry_policy.max_attempts - 1:
return result
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
await self._emit_retry_hook_async(function_name, attempt + 1, delay_ms, result.get("error", "Unknown error"), retry_policy.max_attempts, error_type)
# Wait before retry (async)
await asyncio.sleep(delay_ms / 1000.0)
continue
else:
# Success - return result
return result
except Exception as e:
last_exception = e
# Determine if retryable
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
# Check if retryable and not last attempt
if not is_retryable or attempt == retry_policy.max_attempts - 1:
# Return error dict instead of raising for consistency with sync version
return {"error": f"Error in execute_tool_async: {str(e)}"}
# Determine error type
error_type = self._classify_error_type(None, e)
if not retry_policy.should_retry(error_type, attempt):
return {"error": f"Error in execute_tool_async: {str(e)}"}
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
await self._emit_retry_hook_async(function_name, attempt + 1, delay_ms, str(e), retry_policy.max_attempts, error_type)
# Wait before retry (async)
await asyncio.sleep(delay_ms / 1000.0)
continue
# Should never reach here due to loop logic, but safety fallback
if last_exception:
return {"error": f"Error in execute_tool_async: {str(last_exception)}"}
return {"error": "Maximum retry attempts exceeded"}
async def _execute_tool_async_impl(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Internal async tool execution implementation"""
try:
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
@@ -1286,3 +1378,51 @@ Write the complete compiled report:"""
print(f"\nTo add MCP capabilities, install: pip install {missing_module}")
return None
async def _emit_retry_hook_async(self, tool_name, attempt, delay_ms, error, max_attempts, error_type):
"""Emit ON_RETRY hook event (async version).
Args:
tool_name: Name of the tool being retried
attempt: Current attempt number (1-based)
delay_ms: Delay before retry in milliseconds
error: Error message or description
max_attempts: Maximum number of attempts configured
error_type: Classified error type
"""
try:
from ..hooks import HookEvent, OnRetryInput
# Only emit if we have a hook runner
hook_runner = getattr(self, '_hook_runner', None)
if hook_runner is None:
return
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=tool_name,
attempt=attempt,
delay_ms=delay_ms,
error=error,
max_attempts=max_attempts,
error_type=error_type
)
# Execute hook asynchronously if supported, otherwise use sync
if hasattr(hook_runner, 'execute_async'):
await hook_runner.execute_async(HookEvent.ON_RETRY, retry_input, target=tool_name)
else:
# Fallback to sync execution in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input, target=tool_name)
)
except Exception as e:
# Don't let hook failures break retry logic
logging.debug(f"Failed to emit async retry hook: {e}")
@@ -10,7 +10,7 @@ Unified Handoff System:
- Replaces/absorbs Agent.delegate() and SubagentDelegator functionality
"""
from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, TypeVar, Type, Generic
from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Literal, TypeVar, Type, Generic
from dataclasses import dataclass, field
from enum import Enum
import inspect
@@ -48,6 +48,24 @@ class ContextPolicy(Enum):
NONE = "none" # No context sharing
LAST_N = "last_n" # Share last N messages
@dataclass
class HandoffToolPolicy:
"""
Policy for tool boundary enforcement during handoff.
Defines how tools are filtered when handing off to a target agent.
Default mode is 'intersect' for security by default - sub-agents only
get tools that both they and the source agent have access to.
Attributes:
mode: Tool filtering mode
- "intersect": Target gets intersection of source and target tools (DEFAULT - secure)
- "passthrough": Target keeps its full tool set (legacy behavior - opt-in)
blocked_tools: List of tool names to always strip, regardless of intersection
"""
mode: Literal["intersect", "passthrough"] = "intersect"
blocked_tools: List[str] = field(default_factory=list)
@dataclass
class HandoffConfig:
"""
@@ -61,6 +79,7 @@ class HandoffConfig:
max_context_tokens: Maximum tokens to include in context
max_context_messages: Maximum messages to include (for LAST_N policy)
preserve_system: Whether to preserve system messages in context
tool_policy: Tool boundary enforcement policy (NEW - security by default)
timeout_seconds: Timeout for handoff execution
max_concurrent: Maximum concurrent handoffs (0 = unlimited)
detect_cycles: Enable cycle detection to prevent infinite loops
@@ -77,6 +96,9 @@ class HandoffConfig:
max_context_messages: int = 10
preserve_system: bool = True
# Tool security boundary (NEW)
tool_policy: HandoffToolPolicy = field(default_factory=HandoffToolPolicy)
# Execution control
timeout_seconds: float = 300.0
max_concurrent: int = 5
@@ -101,6 +123,10 @@ class HandoffConfig:
"max_context_tokens": self.max_context_tokens,
"max_context_messages": self.max_context_messages,
"preserve_system": self.preserve_system,
"tool_policy": {
"mode": self.tool_policy.mode,
"blocked_tools": self.tool_policy.blocked_tools.copy()
},
"timeout_seconds": self.timeout_seconds,
"max_concurrent": self.max_concurrent,
"detect_cycles": self.detect_cycles,
@@ -112,9 +138,21 @@ class HandoffConfig:
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'HandoffConfig':
"""Create config from dictionary."""
if "context_policy" in data and isinstance(data["context_policy"], str):
data["context_policy"] = ContextPolicy(data["context_policy"])
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
data_copy = data.copy()
# Handle context_policy enum conversion
if "context_policy" in data_copy and isinstance(data_copy["context_policy"], str):
data_copy["context_policy"] = ContextPolicy(data_copy["context_policy"])
# Handle tool_policy conversion
if "tool_policy" in data_copy and isinstance(data_copy["tool_policy"], dict):
tool_policy_data = data_copy["tool_policy"]
data_copy["tool_policy"] = HandoffToolPolicy(
mode=tool_policy_data.get("mode", "intersect"),
blocked_tools=tool_policy_data.get("blocked_tools", [])
)
return cls(**{k: v for k, v in data_copy.items() if k in cls.__dataclass_fields__})
# Import structured error hierarchy from central errors module
from ..errors import (
@@ -321,6 +359,52 @@ class Handoff:
agent_desc += f" - {self.agent.goal}"
return agent_desc
def _compute_effective_tools(self, source_agent: 'Agent') -> List[Any] | None:
"""
Compute the effective tool set for the target agent based on tool policy.
Args:
source_agent: The agent initiating the handoff
Returns:
List of tools the target agent should have access to during handoff.
Returns None to indicate unrestricted access (for passthrough mode without blocked tools).
Returns [] to indicate no tools allowed (for intersect mode with empty intersection).
"""
policy = self.config.tool_policy
if policy.mode == "passthrough":
# Legacy behavior - target keeps its full tool set, minus blocked tools
if not policy.blocked_tools:
# No restrictions - return None so chat() uses the agent's configured tools
return None
else:
# Filter out blocked tools
target_tools = getattr(self.agent, 'tools', None) or []
effective_tools = [
tool for tool in target_tools
if getattr(tool, 'name', getattr(tool, '__name__', str(tool))) not in policy.blocked_tools
]
return effective_tools
else: # intersect mode (default)
# Security by default - intersection of source and target tools
source_tools = getattr(source_agent, 'tools', None) or []
source_tool_names = {
getattr(tool, 'name', getattr(tool, '__name__', str(tool)))
for tool in source_tools
}
target_tools = getattr(self.agent, 'tools', None) or []
effective_tools = []
for tool in target_tools:
tool_name = getattr(tool, 'name', getattr(tool, '__name__', str(tool)))
if (tool_name in source_tool_names and
tool_name not in policy.blocked_tools):
effective_tools.append(tool)
# In intersect mode, empty list means no tools allowed - this is the security boundary
return effective_tools
def _check_safety(self, source_agent: 'Agent') -> None:
"""
Check safety constraints before handoff.
@@ -482,8 +566,11 @@ class Handoff:
logger.info(f"Programmatic handoff to {self.agent.name}")
# Execute with timeout if sync
response = self.agent.chat(full_prompt)
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Execute with tool boundary enforcement
response = self.agent.chat(full_prompt, tools=effective_tools)
result = HandoffResult(
success=True,
@@ -585,13 +672,16 @@ class Handoff:
logger.info(f"Async handoff to {self.agent.name}")
# Execute - check for async chat method
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Execute with tool boundary enforcement - check for async chat method
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt)
response = await self.agent.achat(full_prompt, tools=effective_tools)
else:
# Run sync chat in executor
# Run sync chat in executor with tool constraint
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
response = await loop.run_in_executor(None, lambda: self.agent.chat(full_prompt, tools=effective_tools))
result = HandoffResult(
success=True,
@@ -719,11 +809,15 @@ class Handoff:
if kwargs and self.input_type:
context_info += f"Context: {kwargs} "
# Execute the target agent
# Execute the target agent with tool boundary enforcement
if last_message:
prompt = context_info + last_message
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
response = self.agent.chat(prompt)
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
response = self.agent.chat(prompt, tools=effective_tools)
result = HandoffResult(
success=True,
@@ -806,13 +900,16 @@ def handoff(
max_concurrent: Optional[int] = None,
detect_cycles: Optional[bool] = None,
max_depth: Optional[int] = None,
# Tool security boundary kwargs (NEW)
tool_policy_mode: Optional[Literal["intersect", "passthrough"]] = None,
blocked_tools: Optional[List[str]] = None,
) -> Handoff:
"""
Create a handoff configuration for delegating tasks to another agent.
This is a convenience function that creates a Handoff instance with the
specified configuration. It supports both the legacy API and the new
unified HandoffConfig.
unified HandoffConfig with tool security boundary enforcement.
Args:
agent: The target agent to hand off to
@@ -827,29 +924,39 @@ def handoff(
max_concurrent: Shorthand for config.max_concurrent
detect_cycles: Shorthand for config.detect_cycles
max_depth: Shorthand for config.max_depth
tool_policy_mode: Tool boundary mode ("intersect" = secure by default, "passthrough" = legacy)
blocked_tools: List of tool names to always block regardless of intersection
Returns:
A configured Handoff instance
Example:
```python
from praisonaiagents import Agent, handoff, HandoffConfig
from praisonaiagents import Agent, handoff, HandoffConfig, HandoffToolPolicy
billing_agent = Agent(name="Billing Agent")
refund_agent = Agent(name="Refund Agent")
refund_agent = Agent(name="Refund Agent")
# Simple usage
# Simple usage (secure by default - tools intersected)
triage_agent = Agent(
name="Triage Agent",
handoffs=[billing_agent, handoff(refund_agent)]
)
# With config
# With tool security config
triage_agent = Agent(
name="Triage Agent",
name="Triage Agent",
handoffs=[
handoff(billing_agent, context_policy="summary", timeout_seconds=60),
handoff(refund_agent, config=HandoffConfig(detect_cycles=True))
handoff(billing_agent,
tool_policy_mode="intersect", # Only shared tools
blocked_tools=["execute_code", "shell_tools"]),
handoff(refund_agent,
config=HandoffConfig(
tool_policy=HandoffToolPolicy(
mode="passthrough", # Legacy behavior
blocked_tools=["dangerous_tool"]
)
))
]
)
```
@@ -870,6 +977,14 @@ def handoff(
if max_depth is not None:
config.max_depth = max_depth
# Apply tool policy kwargs to config
if tool_policy_mode is not None or blocked_tools is not None:
# Create new tool policy with updated values
config.tool_policy = HandoffToolPolicy(
mode=tool_policy_mode if tool_policy_mode is not None else config.tool_policy.mode,
blocked_tools=blocked_tools if blocked_tools is not None else config.tool_policy.blocked_tools
)
return Handoff(
agent=agent,
tool_name_override=tool_name_override,
@@ -403,6 +403,12 @@ class MemoryMixin:
self._session_store.add_user_message(self._session_id, content)
elif role == "assistant":
self._session_store.add_assistant_message(self._session_id, content)
# Keep auto_save index in sync when per-turn persist shares session_id
if self.auto_save and self.auto_save == self._session_id:
with self._history_lock:
self._auto_save_last_index = (
getattr(self, "_auto_save_last_index", 0) + 1
)
self._persist_session_stats()
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
@@ -0,0 +1,273 @@
"""
Message Steering Implementation.
Core implementation of real-time message steering during agent execution.
Integrates with existing message queue infrastructure.
"""
import time
import logging
from typing import List, Optional, Dict, Any
from .message_queue import AgentMessageQueue, MessagePriority
from .protocols import (
MessageSteeringProtocol,
SteeringMessage,
SteeringPriority,
AgentSteeringProtocol
)
logger = logging.getLogger(__name__)
class MessageSteering:
"""
Core message steering implementation.
Provides real-time message queuing and processing for agents
during execution. Uses the existing AgentMessageQueue infrastructure.
"""
def __init__(self, max_messages: int = 50):
"""
Initialize message steering.
Args:
max_messages: Maximum number of pending messages
"""
self._message_queue = AgentMessageQueue(max_size=max_messages)
self._enabled = True
self._last_check = 0.0
self._check_interval = 0.1 # Check every 100ms
def queue_message(self, message: str, priority: int = 5) -> str:
"""Queue a steering message."""
if not self._enabled:
return ""
# Convert priority to SteeringPriority
try:
steering_priority = SteeringPriority(priority)
except ValueError:
steering_priority = SteeringPriority.NORMAL
steering_msg = SteeringMessage(
content=message,
priority=steering_priority,
metadata={"timestamp": time.time()}
)
# Use existing message queue with priority mapping
# Special handling for INTERRUPT - give it maximum priority
if steering_priority == SteeringPriority.INTERRUPT:
queue_priority = MessagePriority.URGENT.value + 1 # Higher than URGENT
else:
queue_priority = min(priority, MessagePriority.URGENT.value)
success = self._message_queue.enqueue(
content=steering_msg,
priority=queue_priority,
metadata={"type": "steering", "original_priority": priority}
)
if success:
msg_id = f"steer_{int(time.time() * 1000)}"
logger.debug(f"Queued steering message: {msg_id}")
return msg_id
else:
logger.warning("Failed to queue steering message - queue full")
return ""
def get_pending_messages(self) -> List[SteeringMessage]:
"""Get all pending steering messages."""
if not self._enabled:
return []
messages = []
all_messages = self._message_queue.get_all()
for msg in all_messages:
if isinstance(msg, SteeringMessage):
messages.append(msg)
return messages
def process_steering(self, context: Optional[Dict[str, Any]] = None) -> bool:
"""
Process pending steering messages.
This is called during agent execution to check for and process
any steering messages. Uses rate limiting to avoid excessive checking,
but allows INTERRUPT priority messages to bypass rate limiting.
Args:
context: Execution context that can be updated with steering info
Returns:
True if messages were processed, False otherwise
"""
if not self._enabled:
return False
# Check if we have high priority messages that bypass rate limiting
has_urgent_messages = False
all_messages = self._message_queue.get_all()
for msg in all_messages:
if isinstance(msg, SteeringMessage) and msg.priority.value >= SteeringPriority.HIGH.value:
has_urgent_messages = True
break
# Rate limiting - only check every 100ms unless we have urgent messages
current_time = time.time()
if not has_urgent_messages and current_time - self._last_check < self._check_interval:
return False
self._last_check = current_time
if self._message_queue.is_empty():
return False
# Process one message per check to avoid blocking
msg_content = self._message_queue.dequeue(timeout=0)
if msg_content is None:
return False
if not isinstance(msg_content, SteeringMessage):
return False
# Update context with steering message
if context is not None:
if "steering_messages" not in context:
context["steering_messages"] = []
context["steering_messages"].append({
"content": msg_content.content,
"priority": msg_content.priority.name,
"timestamp": msg_content.metadata.get("timestamp") if msg_content.metadata else None
})
logger.info(f"Processed steering message: {msg_content.content[:50]}...")
return True
def clear_messages(self) -> int:
"""Clear all pending messages."""
return self._message_queue.clear()
def has_pending_messages(self) -> bool:
"""Check if there are pending steering messages."""
return not self._message_queue.is_empty()
def enable(self) -> None:
"""Enable message steering."""
self._enabled = True
def disable(self) -> None:
"""Disable message steering."""
self._enabled = False
@property
def enabled(self) -> bool:
"""Whether message steering is enabled."""
return self._enabled
class SteeringMixin:
"""
Mixin to add message steering capabilities to Agent class.
This provides the agent interface for message steering while
delegating implementation to MessageSteering.
"""
def _init_message_steering(self, message_steering=False):
"""Initialize message steering if enabled."""
if message_steering is True:
# Use default implementation
self._message_steering = MessageSteering()
elif message_steering is False or message_steering is None:
# Disabled
self._message_steering = None
else:
# Custom implementation provided
self._message_steering = message_steering
def steer(self, message: str, priority: int = 5) -> str:
"""
Send a steering message to the agent during execution.
This can be called while the agent is running to provide
real-time guidance or course corrections.
Args:
message: The steering message
priority: Message priority (1=low, 5=normal, 10=high, 20=urgent, 30=interrupt)
Returns:
Message ID for tracking, empty string if steering disabled
Example:
```python
agent = Agent(name="research", message_steering=True)
# Start long-running task
import threading
def run_task():
return agent.start("Research AI trends comprehensively")
thread = threading.Thread(target=run_task)
thread.start()
# Send steering messages while running
agent.steer("Focus on the business impact, not technical details")
agent.steer("Also include information about market size", priority=10)
```
"""
if self._message_steering is None:
logger.warning("Message steering not enabled - call ignored")
return ""
return self._message_steering.queue_message(message, priority)
def get_steering_status(self) -> Dict[str, Any]:
"""Get current steering status."""
if self._message_steering is None:
return {"enabled": False, "pending_count": 0}
return {
"enabled": self._message_steering.enabled,
"pending_count": len(self._message_steering.get_pending_messages()),
"has_pending": self._message_steering.has_pending_messages()
}
@property
def message_steering_enabled(self) -> bool:
"""Whether message steering is enabled for this agent."""
return self._message_steering is not None and self._message_steering.enabled
def _check_steering_messages(self) -> Optional[str]:
"""
Check for and process steering messages.
This is called during agent execution loops to process
any pending steering messages.
Returns:
Steering message to inject into conversation, or None
"""
if self._message_steering is None:
return None
# Process steering messages
context = {}
if self._message_steering.process_steering(context):
steering_messages = context.get("steering_messages", [])
if steering_messages:
# Get the most recent message
latest = steering_messages[-1]
content = latest["content"]
priority = latest["priority"]
# Format as system message
if priority == "INTERRUPT":
return f"\n[INTERRUPT USER GUIDANCE]: {content}\nPlease stop current work and follow this guidance immediately."
elif priority in ("HIGH", "URGENT"):
return f"\n[URGENT USER GUIDANCE]: {content}\nPlease acknowledge and adjust your approach accordingly."
else:
return f"\n[USER GUIDANCE]: {content}\nPlease consider this feedback as you continue."
return None
@@ -10,6 +10,25 @@ This enables:
These protocols are lightweight and have zero performance impact.
"""
from typing import Protocol, runtime_checkable, Optional, Any, AsyncIterator, Dict, List
from enum import Enum
from dataclasses import dataclass
class SteeringPriority(Enum):
"""Priority levels for steering messages."""
LOW = 1
NORMAL = 5
HIGH = 10
URGENT = 20
INTERRUPT = 30 # Immediate interruption
@dataclass
class SteeringMessage:
"""A steering message with priority and metadata."""
content: str
priority: SteeringPriority = SteeringPriority.NORMAL
metadata: Optional[Dict[str, Any]] = None
@runtime_checkable
@@ -431,6 +450,140 @@ class ManagedBackendProtocol(Protocol):
...
@runtime_checkable
class MessageSteeringProtocol(Protocol):
"""
Protocol for message steering during agent execution.
This protocol enables real-time communication with agents during
long-running operations, allowing users to provide mid-execution
guidance, feedback, and course corrections.
Example:
```python
class BasicMessageSteering:
def queue_message(self, message: str, priority: int = 5) -> str:
# Add to internal queue
return "msg_001"
def get_pending_messages(self) -> List[SteeringMessage]:
# Return queued messages
return []
def process_steering(self, context: dict) -> bool:
# Check for messages and update context
return False
agent = Agent(name="assistant", message_steering=BasicMessageSteering())
```
"""
def queue_message(self, message: str, priority: int = 5) -> str:
"""
Queue a steering message for the agent.
Args:
message: The steering message content
priority: Message priority (higher = more urgent)
Returns:
Message ID for tracking
"""
...
def get_pending_messages(self) -> List[Any]:
"""
Get all pending steering messages.
Returns:
List of pending messages ordered by priority
"""
...
def process_steering(self, context: Optional[Dict[str, Any]] = None) -> bool:
"""
Process any pending steering messages and update execution context.
This method is called during agent execution to check for
and process any steering messages that have been queued.
Args:
context: Optional execution context to update
Returns:
True if messages were processed, False otherwise
"""
...
def clear_messages(self) -> int:
"""
Clear all pending messages.
Returns:
Number of messages cleared
"""
...
def has_pending_messages(self) -> bool:
"""
Check if there are pending steering messages.
Returns:
True if messages are pending, False otherwise
"""
...
@property
def enabled(self) -> bool:
"""
Whether message steering is enabled.
Returns:
True if steering is enabled, False otherwise
"""
...
@runtime_checkable
class AgentSteeringProtocol(Protocol):
"""
Protocol for agents that support message steering.
This extends the agent interface with steering capabilities,
allowing real-time message injection during execution.
"""
def steer(self, message: str, priority: int = 5) -> str:
"""
Send a steering message to the agent during execution.
This can be called while the agent is running to provide
real-time guidance or course corrections.
Args:
message: The steering message
priority: Message priority (higher = more urgent)
Returns:
Message ID for tracking
"""
...
def get_steering_status(self) -> Dict[str, Any]:
"""
Get current steering status.
Returns:
Dict with pending message count, last processed time, etc.
"""
...
@property
def message_steering_enabled(self) -> bool:
"""Whether message steering is enabled for this agent."""
...
__all__ = [
'AgentProtocol',
'RunnableAgentProtocol',
@@ -441,4 +594,8 @@ __all__ = [
'HttpLauncherProtocol',
'McpLauncherProtocol',
'ManagedBackendProtocol',
'SteeringPriority',
'SteeringMessage',
'MessageSteeringProtocol',
'AgentSteeringProtocol',
]
@@ -353,9 +353,9 @@ class RouterAgent(Agent):
return report
def _build_system_prompt(self) -> str:
def _build_system_prompt(self, tools=None, **kwargs) -> str:
"""Build system prompt (inherited from parent but can be customized)."""
base_prompt = super()._build_system_prompt()
base_prompt = super()._build_system_prompt(tools=tools, **kwargs)
# Add multi-model context if needed
if self.routing_strategy == "auto":
@@ -196,6 +196,19 @@ class ToolExecutionMixin:
))
try:
# Check for steering messages before tool execution
if hasattr(self, '_check_steering_messages'):
try:
steering_msg = self._check_steering_messages()
if steering_msg:
# Check if steering message indicates interruption priority
# (SteeringMixin formats HIGH/URGENT as "[URGENT USER GUIDANCE]" and INTERRUPT as "[INTERRUPT USER GUIDANCE]")
if "[URGENT USER GUIDANCE]" in steering_msg or "[INTERRUPT USER GUIDANCE]" in steering_msg:
logger.info(f"Tool {function_name} execution interrupted by high-priority steering")
return f"Tool execution interrupted by user guidance: {steering_msg}"
except Exception as e:
logger.warning(f"Steering check failed, continuing with tool execution: {e}")
# Trigger BEFORE_TOOL hook
from ..hooks import HookEvent, BeforeToolInput
before_tool_input = BeforeToolInput(
@@ -700,7 +713,111 @@ class ToolExecutionMixin:
return None, arguments
def _execute_tool_with_circuit_breaker(self, function_name, arguments):
"""Execute tool with circuit breaker protection.
"""Execute tool with retry policy and circuit breaker protection.
Args:
function_name: Name of the tool to execute
arguments: Arguments for the tool
Returns:
Tool execution result or circuit breaker error
"""
# Get retry policy (tool-level > agent-level > default)
retry_policy = self._get_tool_retry_policy(function_name)
last_exception = None
# Retry loop with exponential backoff
for attempt in range(retry_policy.max_attempts):
try:
result = self._execute_tool_with_circuit_breaker_impl(function_name, arguments)
# Check if result is an error that should be retried
if isinstance(result, dict) and result.get("error"):
# Skip retry for non-retryable errors (approval, permission, etc.)
if (result.get("approval_denied") or
result.get("permission_denied") or
result.get("approval_error") or
result.get("circuit_open")):
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
# Check if we should retry
if not retry_policy.should_retry(error_type, attempt):
return result
# Don't retry on last attempt
if attempt == retry_policy.max_attempts - 1:
return result
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
self._emit_retry_hook(function_name, attempt + 1, delay_ms, result.get("error", "Unknown error"), retry_policy.max_attempts, error_type)
# Wait before retry
time.sleep(delay_ms / 1000.0)
continue
else:
# Success - return result
return result
except ToolExecutionError as e:
last_exception = e
# Check if the error is retryable
if not e.is_retryable or attempt == retry_policy.max_attempts - 1:
raise
# Determine error type
error_type = self._classify_error_type(None, e)
if not retry_policy.should_retry(error_type, attempt):
raise
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
self._emit_retry_hook(function_name, attempt + 1, delay_ms, str(e), retry_policy.max_attempts, error_type)
# Wait before retry
time.sleep(delay_ms / 1000.0)
continue
except Exception as e:
# Wrap in ToolExecutionError for consistency
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
wrapped_error = ToolExecutionError(
f"Tool '{function_name}' failed: {e}",
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
# Check if retryable and not last attempt
if not is_retryable or attempt == retry_policy.max_attempts - 1:
raise wrapped_error from e
# Determine error type
error_type = self._classify_error_type(None, wrapped_error)
if not retry_policy.should_retry(error_type, attempt):
raise wrapped_error from e
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
self._emit_retry_hook(function_name, attempt + 1, delay_ms, str(e), retry_policy.max_attempts, error_type)
# Wait before retry
time.sleep(delay_ms / 1000.0)
continue
# Should never reach here due to loop logic, but safety fallback
if last_exception:
raise last_exception
return {"error": "Maximum retry attempts exceeded"}
def _execute_tool_with_circuit_breaker_impl(self, function_name, arguments):
"""Execute tool with circuit breaker protection (internal implementation).
Args:
function_name: Name of the tool to execute
@@ -1110,3 +1227,110 @@ class ToolExecutionMixin:
else:
return f"Unknown bridge tool: {function_name}"
def _get_tool_retry_policy(self, tool_name):
"""Get retry policy for a tool (tool-level > agent-level > default).
Args:
tool_name: Name of the tool to get retry policy for
Returns:
RetryPolicy instance
"""
from ..tools.retry import RetryPolicy
# Check for tool-level retry policy first
tools = getattr(self, 'tools', [])
# Handle non-iterable tools (e.g., single MCP instance)
if not isinstance(tools, (list, tuple)):
tools = [] # MCP or single tool instance - no tool-level policy lookup
for tool in tools:
if (callable(tool) and
getattr(tool, '__name__', '') == tool_name and
hasattr(tool, 'retry_policy')):
return tool.retry_policy
# Check for agent-level retry policy
agent_policy = getattr(self, '_tool_retry_policy', None)
if agent_policy is not None:
return agent_policy
# Return default retry policy (cached class-level instance)
if not hasattr(ToolExecutionMixin, '_default_retry_policy'):
ToolExecutionMixin._default_retry_policy = RetryPolicy()
return ToolExecutionMixin._default_retry_policy
def _classify_error_type(self, error_dict, exception):
"""Classify error type for retry policy matching.
Args:
error_dict: Error dictionary from tool execution (if any)
exception: Exception that was raised (if any)
Returns:
String error type for retry policy checking
"""
# Check error dict first
if error_dict and isinstance(error_dict, dict):
error_msg = error_dict.get("error", "").lower()
if "timeout" in error_msg or "timed out" in error_msg:
return "timeout"
elif "rate" in error_msg and "limit" in error_msg:
return "rate_limit"
elif "connection" in error_msg or "network" in error_msg:
return "connection_error"
# Check exception type
if exception:
exc_msg = str(exception).lower()
exc_type = type(exception).__name__.lower()
if "timeout" in exc_msg or "timeout" in exc_type:
return "timeout"
elif "rate" in exc_msg and "limit" in exc_msg:
return "rate_limit"
elif ("connection" in exc_msg or "network" in exc_msg or
"connection" in exc_type):
return "connection_error"
return "unknown"
def _emit_retry_hook(self, tool_name, attempt, delay_ms, error, max_attempts, error_type):
"""Emit ON_RETRY hook event.
Args:
tool_name: Name of the tool being retried
attempt: Current attempt number (1-based)
delay_ms: Delay before retry in milliseconds
error: Error message or description
max_attempts: Maximum number of attempts configured
error_type: Classified error type
"""
try:
from ..hooks import HookEvent, OnRetryInput
# Only emit if we have a hook runner
hook_runner = getattr(self, '_hook_runner', None)
if hook_runner is None:
return
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=tool_name,
attempt=attempt,
delay_ms=delay_ms,
error=error,
max_attempts=max_attempts,
error_type=error_type
)
# Execute hook synchronously
hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input, target=tool_name)
except Exception as e:
# Don't let hook failures break retry logic
logging.debug(f"Failed to emit retry hook: {e}")
@@ -725,6 +725,9 @@ class ExecutionConfig:
# Retry settings
max_retry_limit: int = 2
# Tool call limits (loop protection)
max_tool_calls_per_turn: int = 10
# Code execution (consolidated from allow_code_execution + code_execution_mode)
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
@@ -840,6 +843,67 @@ class ExecutionConfig:
)
@dataclass
class ToolConfig:
"""
Configuration for tool execution behavior.
Configuration for tool execution behavior including timeout, retry policy, and parallel execution.
Usage:
# Simple enable with defaults
Agent(tool_config=ToolConfig())
# With custom settings
Agent(tool_config=ToolConfig(
timeout=60,
retry_policy=RetryPolicy(max_attempts=5),
parallel=True,
))
# With timeout only
Agent(tool_config=ToolConfig(timeout=30))
"""
# Tool execution timeout in seconds
timeout: Optional[int] = None
# Retry policy for tool execution with exponential backoff
retry_policy: Optional[Any] = None # RetryPolicy instance
# Enable parallel execution of batched LLM tool calls
parallel: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"timeout": self.timeout,
"retry_policy": (
self.retry_policy.to_dict()
if hasattr(self.retry_policy, 'to_dict')
else self.retry_policy
),
"parallel": self.parallel,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ToolConfig":
"""Create ToolConfig from dictionary."""
retry_policy = data.get("retry_policy")
if isinstance(retry_policy, dict):
try:
from ..tools.retry import RetryPolicy
retry_policy = RetryPolicy.from_dict(retry_policy)
except ImportError:
# Keep as dict if RetryPolicy not available
pass
return cls(
timeout=data.get("timeout"),
retry_policy=retry_policy,
parallel=data.get("parallel", False),
)
@dataclass
class TemplateConfig:
"""
@@ -1185,6 +1249,7 @@ HooksParam = Union[List[Any], HooksConfig]
SkillsParam = Union[List[str], SkillsConfig]
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
ToolSearchParam = Union[bool, str, Dict[str, Any], ToolSearchConfig]
ToolParam = Union[bool, ToolConfig] # bool = defaults, ToolConfig = custom
# =============================================================================
@@ -1451,6 +1516,19 @@ def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
return value
def resolve_tools(value: ToolParam) -> Optional[ToolConfig]:
"""Resolve tools= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ToolConfig()
if isinstance(value, dict):
return ToolConfig.from_dict(value)
if isinstance(value, ToolConfig):
return value
return value
__all__ = [
# Enums
"MemoryBackend",
@@ -1471,6 +1549,7 @@ __all__ = [
"WebConfig",
"OutputConfig",
"ExecutionConfig",
"ToolConfig",
"TemplateConfig",
"CachingConfig",
"HooksConfig",
@@ -1497,7 +1576,8 @@ __all__ = [
"HooksParam",
"SkillsParam",
"AutonomyParam",
"ToolSearchParam",
"ToolSearchParam",
"ToolParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
@@ -1510,4 +1590,5 @@ __all__ = [
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
"resolve_tools",
]
@@ -178,7 +178,14 @@ class OnErrorInput(HookInput):
@dataclass
class OnRetryInput(HookInput):
"""Input for OnRetry hooks."""
"""Input for OnRetry hooks fired during tool execution retries."""
tool_name: str = ""
attempt: int = 1
delay_ms: int = 0
error: str = ""
max_attempts: int = 0
error_type: str = "unknown"
# Legacy fields for backward compatibility
retry_count: int = 0
max_retries: int = 3
error_message: str = ""
@@ -187,6 +194,13 @@ class OnRetryInput(HookInput):
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"tool_name": self.tool_name,
"attempt": self.attempt,
"delay_ms": self.delay_ms,
"error": self.error,
"max_attempts": self.max_attempts,
"error_type": self.error_type,
# Legacy fields
"retry_count": self.retry_count,
"max_retries": self.max_retries,
"error_message": self.error_message,
@@ -2066,6 +2066,7 @@ Now provide your final answer using this result. Summarize the information natur
task_id: Optional[str] = None,
execute_tool_fn: Optional[Callable] = None,
parallel_tool_calls: bool = False, # Gap 2: Enable parallel tool execution
max_tool_calls_per_turn: int = 10, # Loop guardrails
stream: bool = True,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
@@ -2190,6 +2191,7 @@ Now provide your final answer using this result. Summarize the information natur
# Sequential tool calling loop - similar to agent.py
max_iterations = self.max_iter # Use configurable iteration limit
iteration_count = 0
tool_call_count = 0 # Track total tool calls for guardrails
final_response_text = ""
response_text = "" # Initialize to prevent UnboundLocalError on API errors
stored_reasoning_content = None # Store reasoning content from tool execution
@@ -3049,6 +3051,18 @@ Now provide your final answer using this result. Summarize the information natur
tool_results = [] # Store current iteration tool results
tool_result_mapping = {} # Store function results by name for Ollama chaining
# Guardrail: Check tool call limit to prevent infinite loops
# Allow execution if we haven't reached the limit yet, but limit the batch size
if tool_call_count >= max_tool_calls_per_turn:
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
break
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
# Limit the batch to stay within the total limit
remaining_calls = max_tool_calls_per_turn - tool_call_count
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
for tool_call in tool_calls:
# Handle both object and dict access patterns
is_ollama = self._is_ollama_provider()
@@ -3069,7 +3083,8 @@ Now provide your final answer using this result. Summarize the information natur
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result}")
tool_call_count += 1 # Increment tool call counter for guardrails
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result} (call #{tool_call_count})")
tool_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -3581,6 +3596,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id: Optional[str] = None,
execute_tool_fn: Optional[Callable] = None,
parallel_tool_calls: bool = False, # Gap 2: Enable parallel tool execution
max_tool_calls_per_turn: int = 10, # Loop guardrails
**kwargs
):
"""Generator that yields real-time response chunks from the LLM.
@@ -3727,6 +3743,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# After streaming completes, handle tool calls if present
if tool_calls and execute_tool_fn:
# Guardrail: Check tool call limit to prevent infinite loops
if len(tool_calls) > max_tool_calls_per_turn:
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
error_message = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
yield error_message
return
# Add assistant message with tool calls to conversation
if self._is_ollama_provider():
messages.append({
@@ -3903,6 +3926,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_description: Optional[str] = None,
task_id: Optional[str] = None,
execute_tool_fn: Optional[Callable] = None,
max_tool_calls_per_turn: int = 10, # Loop guardrails
stream: bool = True,
**kwargs
) -> str:
@@ -3981,6 +4005,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Initialize variables for iteration loop
max_iterations = self.max_iter # Use configurable iteration limit
iteration_count = 0
tool_call_count = 0 # Track total tool calls for guardrails
final_response_text = ""
stored_reasoning_content = None # Store reasoning content from tool execution
accumulated_tool_results = [] # Store all tool results across iterations
@@ -4053,6 +4078,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"tool_calls": serializable_tool_calls,
})
# Guardrail: Check tool call limit to prevent infinite loops
# Allow execution if we haven't reached the limit yet, but limit the batch size
if tool_call_count >= max_tool_calls_per_turn:
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
break
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
# Limit the batch to stay within the total limit
remaining_calls = max_tool_calls_per_turn - tool_call_count
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
@@ -4060,6 +4097,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
else:
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
tool_call_count += 1 # Increment tool call counter for guardrails
accumulated_tool_results.append(tool_result)
if verbose:
@@ -4266,6 +4304,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"tool_calls": serializable_tool_calls
})
# Guardrail: Check tool call limit to prevent infinite loops
# Allow execution if we haven't reached the limit yet, but limit the batch size
if tool_call_count >= max_tool_calls_per_turn:
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
break
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
# Limit the batch to stay within the total limit
remaining_calls = max_tool_calls_per_turn - tool_call_count
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
tool_results = [] # Store current iteration tool results
for tool_call in tool_calls:
# Handle both object and dict access patterns
@@ -4277,6 +4327,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
tool_result = await execute_tool_fn(function_name, arguments)
tool_call_count += 1 # Increment tool call counter for guardrails
tool_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -4915,6 +4966,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
'task_name', 'task_description', 'task_id', # Task metadata
'execute_tool_fn', 'stream_callback', 'emit_events', # Callbacks
'console', # Rich console
'max_tool_calls_per_turn', 'parallel_tool_calls', # Tool execution settings
]
for param in internal_params:
params.pop(param, None)
@@ -11,6 +11,7 @@ from __future__ import annotations
import os
import uuid
import json
import logging
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
@@ -340,14 +341,31 @@ class Session:
pass
if session_store is not None:
# Use SessionStore for conversation history
# Replace atomically to avoid duplicate appends on repeated save_state()
session_id = f"{self.session_id}_{agent_key}"
for msg in chat_history:
if isinstance(msg, dict):
messages = [
{
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
}
for msg in chat_history
if isinstance(msg, dict)
]
if not messages:
logging.debug(f"No chat history to persist for session {session_id}")
elif hasattr(session_store, "set_chat_history"):
session_store.set_chat_history(session_id, messages)
else:
# Fallback to add_message - may create duplicates on repeated calls
logging.warning(
f"Session store lacks 'set_chat_history' method. Using fallback "
f"'add_message' which may create duplicates on repeated save_state() calls."
)
for msg in messages:
session_store.add_message(
session_id,
role=msg.get("role", "user"),
content=msg.get("content", ""),
role=msg["role"],
content=msg["content"],
)
else:
# Fallback to Memory.store_short_term() for backward compatibility
@@ -70,7 +70,8 @@ class FunctionTool(BaseTool):
description: Optional[str] = None,
version: str = "1.0.0",
availability: Optional[Callable[[], tuple[bool, str]]] = None,
dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None
dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
retry_policy: Optional[Any] = None
):
self._func = func
self.name = name or func.__name__
@@ -78,6 +79,7 @@ class FunctionTool(BaseTool):
self.version = version
self._availability = availability
self._schema_override = dynamic_schema_overrides
self.retry_policy = retry_policy
# Detect injected parameters
self._injected_params = get_injected_params(func)
@@ -205,7 +207,8 @@ def tool(
description: Optional[str] = None,
version: str = "1.0.0",
availability: Optional[Callable[[], tuple[bool, str]]] = None,
dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None
dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
retry_policy: Optional[Any] = None
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
"""Decorator to convert a function into a tool.
@@ -223,6 +226,10 @@ def tool(
@tool(availability=lambda: (bool(os.getenv("API_KEY")), "API_KEY missing"))
def my_func(x: str) -> str:
return x
@tool(retry_policy=RetryPolicy(max_attempts=5))
def my_func(x: str) -> str:
return x
Args:
func: The function to wrap (when used without parentheses)
@@ -231,6 +238,7 @@ def tool(
version: Tool version (default: "1.0.0")
availability: Function that returns (is_available, reason) tuple
dynamic_schema_overrides: Function to dynamically modify tool schema at runtime
retry_policy: RetryPolicy for tool execution with exponential backoff
Returns:
FunctionTool instance that wraps the function
@@ -242,7 +250,8 @@ def tool(
description=description,
version=version,
availability=availability,
dynamic_schema_overrides=dynamic_schema_overrides
dynamic_schema_overrides=dynamic_schema_overrides,
retry_policy=retry_policy
)
# Validate the tool at creation time for early error detection
@@ -21,13 +21,28 @@ from __future__ import annotations
import logging
from praisonaiagents._logging import get_logger
import os
from typing import Optional
from typing import Optional, Union, List
logger = get_logger(__name__)
# Lazy-loaded AgentMail client (module-level singleton)
_client = None
def _parse_email_list(emails: Union[str, List[str]]) -> List[str]:
"""Parse email addresses from string or list format.
Args:
emails: Email address(es) as string (comma-separated) or list
Returns:
List of individual email addresses
"""
if not emails:
return []
if isinstance(emails, str):
return [email.strip() for email in emails.split(',') if email.strip()]
return [email.strip() for email in emails if email and email.strip()]
def _detect_backend() -> str:
"""Detect which email backend is available.
@@ -83,15 +98,28 @@ def _normalize_message_id(message_id: str) -> str:
return f"<{message_id}>"
return message_id
def _agentmail_send_email(to: str, subject: str, body: str) -> str:
def _agentmail_send_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.messages.send(inbox_id, to=to, subject=subject, text=body)
kwargs = {"to": to, "subject": subject, "text": body}
cc_list = _parse_email_list(cc) if cc else []
bcc_list = _parse_email_list(bcc) if bcc else []
if cc_list:
kwargs["cc"] = cc_list
if bcc_list:
kwargs["bcc"] = bcc_list
result = client.inboxes.messages.send(inbox_id, **kwargs)
msg_id = getattr(result, "message_id", "unknown")
thread_id = getattr(result, "thread_id", "")
logger.info(f"Email sent to {to}: {msg_id}")
return f"Email sent successfully to {to}. Message ID: {msg_id}, Thread ID: {thread_id}"
recipients = [to] + cc_list + bcc_list
logger.info(f"Email sent to {', '.join(recipients)}: {msg_id}")
return f"Email sent successfully to {', '.join(recipients)}. Message ID: {msg_id}, Thread ID: {thread_id}"
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return f"Failed to send email: {e}"
@@ -232,11 +260,21 @@ def _agentmail_forward_email(message_id: str, to: str, note: Optional[str] = Non
logger.error(f"Failed to forward email {message_id}: {e}")
return f"Failed to forward email: {e}"
def _agentmail_draft_email(to: str, subject: str, body: str) -> str:
def _agentmail_draft_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.drafts.create(inbox_id, to=[to], subject=subject, text=body)
kwargs = {"to": [to] if isinstance(to, str) else to, "subject": subject, "text": body}
cc_list = _parse_email_list(cc) if cc else []
bcc_list = _parse_email_list(bcc) if bcc else []
if cc_list:
kwargs["cc"] = cc_list
if bcc_list:
kwargs["bcc"] = bcc_list
result = client.inboxes.drafts.create(inbox_id, **kwargs)
draft_id = getattr(result, "draft_id", "unknown")
logger.info(f"Draft created: {draft_id}")
return f"Draft created (ID: {draft_id}). Use send_draft to send it."
@@ -349,7 +387,7 @@ def _extract_body_preview(msg, max_len: int = 100) -> str:
)[:max_len]
return ""
def _smtp_send_email(to: str, subject: str, body: str) -> str:
def _smtp_send_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
import smtplib
from email.mime.text import MIMEText
try:
@@ -358,12 +396,27 @@ def _smtp_send_email(to: str, subject: str, body: str) -> str:
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
# Parse CC and BCC lists
cc_list = _parse_email_list(cc) if cc else []
bcc_list = _parse_email_list(bcc) if bcc else []
# Add CC header if provided (BCC is not added to headers by design)
all_recipients = [to]
if cc_list:
msg["CC"] = ", ".join(cc_list)
all_recipients.extend(cc_list)
if bcc_list:
all_recipients.extend(bcc_list)
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls()
server.login(email_addr, password)
server.send_message(msg)
logger.info(f"SMTP email sent to {to}")
return f"Email sent successfully to {to} from {email_addr}"
# Send to all recipients (to, cc, bcc)
server.send_message(msg, to_addrs=all_recipients)
logger.info(f"SMTP email sent to {', '.join(all_recipients)}")
return f"Email sent successfully to {', '.join(all_recipients)} from {email_addr}"
except ValueError as e:
return str(e)
except Exception as e:
@@ -562,7 +615,7 @@ def _smtp_archive_email(message_id: str) -> str:
logger.error(f"Failed to archive email {message_id}: {e}")
return f"Failed to archive email: {e}"
def _smtp_draft_email(to: str, subject: str, body: str) -> str:
def _smtp_draft_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
import imaplib
import time
from email.mime.text import MIMEText
@@ -572,6 +625,17 @@ def _smtp_draft_email(to: str, subject: str, body: str) -> str:
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
# Parse CC and BCC lists
cc_list = _parse_email_list(cc) if cc else []
bcc_list = _parse_email_list(bcc) if bcc else []
# Add CC header if provided (BCC is not added to headers in drafts)
if cc_list:
msg["CC"] = ", ".join(cc_list)
if bcc_list:
msg["Bcc"] = ", ".join(bcc_list)
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
mail.login(email_addr, password)
# Gmail uses [Gmail]/Drafts, others use Drafts or DRAFTS
@@ -582,8 +646,11 @@ def _smtp_draft_email(to: str, subject: str, body: str) -> str:
drafts_folder = "Drafts"
mail.append(drafts_folder, "\\Draft", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
mail.logout()
recipients = [to] + cc_list + bcc_list
logger.info(f"Draft saved to {drafts_folder}")
return f"Draft saved to {drafts_folder} (To: {to}, Subject: {subject})"
return f"Draft saved to {drafts_folder} (To: {', '.join(recipients)}, Subject: {subject})"
except ValueError as e:
return str(e)
except Exception as e:
@@ -594,8 +661,8 @@ def _smtp_draft_email(to: str, subject: str, body: str) -> str:
# Generic Tools — Auto-detect backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to someone.
def send_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
"""Send an email to someone with optional CC and BCC recipients.
Auto-detects backend: AgentMail (if AGENTMAIL_API_KEY set) or
SMTP (if EMAIL_ADDRESS + EMAIL_PASSWORD set).
@@ -604,14 +671,18 @@ def send_email(to: str, subject: str, body: str) -> str:
to: Recipient email address (e.g. bob@example.com)
subject: Email subject line
body: Email body text content
cc: Optional CC (carbon copy) recipient(s). Can be a single email address or
comma-separated string or list of email addresses
bcc: Optional BCC (blind carbon copy) recipient(s). Can be a single email address or
comma-separated string or list of email addresses
Returns:
Confirmation message with the sent message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_send_email(to, subject, body)
return _smtp_send_email(to, subject, body)
return _agentmail_send_email(to, subject, body, cc, bcc)
return _smtp_send_email(to, subject, body, cc, bcc)
def list_emails(limit: int = 10) -> str:
"""List recent emails in the inbox.
@@ -730,8 +801,8 @@ def forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
return _agentmail_forward_email(message_id, to, note)
return "Forward not supported with IMAP. Use read_email + send_email as a workaround."
def draft_email(to: str, subject: str, body: str) -> str:
"""Create an email draft without sending it.
def draft_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
"""Create an email draft without sending it, with optional CC and BCC.
Auto-detects backend: AgentMail or IMAP.
AgentMail: use send_draft() to send later.
@@ -741,14 +812,16 @@ def draft_email(to: str, subject: str, body: str) -> str:
to: Recipient email address
subject: Email subject line
body: Email body text
cc: Optional CC (carbon copy) recipient email address or comma-separated list
bcc: Optional BCC (blind carbon copy) recipient email address or comma-separated list
Returns:
Confirmation with draft details
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_draft_email(to, subject, body)
return _smtp_draft_email(to, subject, body)
return _agentmail_draft_email(to, subject, body, cc, bcc)
return _smtp_draft_email(to, subject, body, cc, bcc)
def send_draft(draft_id: str) -> str:
"""Send a previously created email draft.
@@ -827,8 +900,8 @@ def create_inbox(display_name: Optional[str] = None) -> str:
# Backward-compatible aliases (smtp_ prefix)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def smtp_send_email(to: str, subject: str, body: str) -> str:
"""Send an email using SMTP (Gmail, Outlook, etc.).
def smtp_send_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
"""Send an email using SMTP (Gmail, Outlook, etc.) with optional CC/BCC.
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
@@ -836,11 +909,13 @@ def smtp_send_email(to: str, subject: str, body: str) -> str:
to: Recipient email address
subject: Email subject line
body: Email body text
cc: Optional CC (carbon copy) recipient email address
bcc: Optional BCC (blind carbon copy) recipient email address
Returns:
Confirmation message
"""
return _smtp_send_email(to, subject, body)
return _smtp_send_email(to, subject, body, cc, bcc)
def smtp_read_inbox(limit: int = 10, folder: str = "INBOX") -> str:
"""Read recent emails from your mailbox using IMAP.
@@ -890,8 +965,8 @@ def smtp_archive_email(message_id: str) -> str:
"""
return _smtp_archive_email(message_id)
def smtp_draft_email(to: str, subject: str, body: str) -> str:
"""Save an email draft using IMAP APPEND.
def smtp_draft_email(to: str, subject: str, body: str, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None) -> str:
"""Save an email draft using IMAP APPEND with optional CC/BCC.
Saves to Drafts folder ([Gmail]/Drafts for Gmail).
@@ -899,8 +974,10 @@ def smtp_draft_email(to: str, subject: str, body: str) -> str:
to: Recipient email address
subject: Email subject line
body: Email body text
cc: Optional CC (carbon copy) recipient email address
bcc: Optional BCC (blind carbon copy) recipient email address
Returns:
Confirmation message
"""
return _smtp_draft_email(to, subject, body)
return _smtp_draft_email(to, subject, body, cc, bcc)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.53"
version = "1.6.55"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more