Compare commits

...
81 Commits
Author SHA1 Message Date
Mervin Praison c77259eda7 Release v4.5.73 2026-03-17 12:11:44 +00:00
Mervin Praison e69e726ca6 refactor(tools): rename web_crawl.py to web_crawl_tools.py
Updated the import paths in TOOL_MAPPINGS to reflect the new file name.
Bumped version to 1.5.73 in pyproject.toml and uv.lock for the change.
2026-03-17 12:08:30 +00:00
Mervin Praison 3130ae4e19 Release v4.5.72 2026-03-17 11:17:13 +00:00
Mervin Praison c872be20ed feat(email_tools): enhance backend detection and streamline usage
Updated email_tools to auto-detect available email backends (AgentMail or SMTP) based on environment variables. This refactor simplifies usage by providing generic tools while maintaining backend-specific functionalities. Enhanced documentation to reflect these changes and improve usability.
2026-03-17 11:14:39 +00:00
Mervin Praison 4548783a40 Release v4.5.71 2026-03-17 10:41:33 +00:00
Mervin Praison 5b1c39d496 feat(bots): add mode and webhook port configurability
Enhanced BotConfig and related classes to support multiple operation modes (poll, ws, webhook, hybrid) and configurable webhook port. Updated documentation and validation for new parameters in the AgentMailBot for improved flexibility in message handling. Bumped version to 1.5.71.
2026-03-17 10:38:13 +00:00
Mervin Praison 1e3933f5f3 Release v4.5.70 2026-03-17 08:14:59 +00:00
Mervin Praison 203ebe4960 ```
feat(email_tools): add reply_email and create_inbox functions

Implemented reply_email for responding to emails and create_inbox for
creating new email inboxes in the AgentMail system. Updated integration
in the tool mappings and profiles to include new functionalities.
Enhanced tests to cover the new features.
```
2026-03-17 08:12:11 +00:00
Mervin Praison e144d840bb Release v4.5.69 2026-03-16 21:43:41 +00:00
Mervin Praison 47bff65413 feat(mcp): enhance command validation in MCPHandler
Added an allowlist for MCP server commands to improve security by
rejecting disallowed and dangerous commands in the MCPHandler class.
Updated tests to verify the new validation logic and ensure expected
executables are accepted. Bumped version to 1.5.69.
2026-03-16 21:39:47 +00:00
Mervin Praison 66bd9ee2a9 Release v4.5.68 2026-03-16 17:48:36 +00:00
github-actions[bot] 1dd5d7e706 docs: auto-update api.md [skip ci] 2026-03-16 17:45:42 +00:00
Mervin Praison fee74fab53 ```
feat(email): add AgentMail tools for sending and managing emails

- Introduced send_email, list_emails, and read_email functions.
- Added email_tools module for agent interaction.
- Updated tool mappings to include email functionalities.
- Included example YAML configuration for an AgentMail bot.
- Enhanced tests for email tools and added new test cases.
```
2026-03-16 17:45:17 +00:00
Mervin Praison 003f9371a1 Release v4.5.67 2026-03-16 16:31:48 +00:00
Mervin Praison 7234cd5682 ```
feat(bots): add email bot examples and functionalities

This commit introduces a new email bot feature, enabling agents to
interact through email via IMAP/SMTP. It includes example usage,
custom configurations, and a multi-platform approach, enhancing the
bot's usability in diverse environments.
```
2026-03-16 16:31:23 +00:00
Mervin Praison 4373fb3adb docs(README): update performance metrics and clarify agent orchestration
Refined performance section to emphasize speed and reduced overhead.
Updated terminology for clarity in the external agents description.
2026-03-16 12:11:56 +00:00
Mervin Praison e87dd8e37a ```
docs(readme): refine performance section and enhance descriptions

Updated the performance metrics and descriptions to clarify advantages of PraisonAI. Improved consistency in terminology for better readability.
```
2026-03-16 12:08:35 +00:00
Mervin Praison 872cd87638 Release v4.5.65 2026-03-16 12:00:17 +00:00
Mervin Praison 8ea0065f83 ```
feat(bots): add EmailBot for IMAP polling and SMTP sending

This commit introduces the EmailBot, enabling integration with email
services using IMAP for receiving messages and SMTP for sending.
Updates include new environment variables for configuration,
enhancements to the bot registry, and modifications to schema and
initialization logic for seamless integration.
```
2026-03-16 11:58:55 +00:00
Mervin Praison e922eb1e64 Release v4.5.64 2026-03-16 10:02:40 +00:00
Mervin Praison e364aa7519 Release v4.5.63 2026-03-15 15:36:23 +00:00
Mervin Praison e67a11db1f ```
fix(agent): handle potential None value for agent name

Updated agent hash generation to default to "agent" when name is None.
Bumped version to 1.5.63 to reflect this change.
```
2026-03-15 15:31:02 +00:00
Mervin Praison 9071af74ea Merge pull request #1114 from jnMetaCode/fix-agent-name-none-crash
fix: handle None agent name when generating history session ID
2026-03-15 07:12:47 +00:00
Mervin Praison 7821916c56 Merge pull request #1115 from jnMetaCode/fix-model-windows-duplicate-key
fix: correct duplicate gpt-4o-mini key in MODEL_WINDOWS to gpt-4o
2026-03-15 07:12:16 +00:00
Mervin Praison dd1b9a7f1f Release v4.5.62 2026-03-15 05:30:13 +00:00
Mervin Praison 3b006be069 ```
feat(tests): add deep performance profiling integration test

Introduced a new integration test to run cProfile-wrapped Agent prompts.
This test stores detailed function-level breakdown as JSONL for regression
tracking and comparison of performance over time. Updated version number
and modified documentation accordingly.
```
2026-03-15 05:29:29 +00:00
Mervin Praison 4d80c60ecb ```
feat(llm): add support for OpenAI Responses API

- Implemented methods to check model compatibility with the Responses API.
- Added functionality to transform Chat Completions messages to Responses API parameters.
- Updated agent and budgeter context for new model types and limits.
- Cleaned up test cases for database integration to remove unnecessary parameters.
```
2026-03-14 11:53:47 +00:00
Mervin Praison 0cdfde2c04 Release v4.5.60 2026-03-13 16:57:44 +00:00
Mervin Praison 98e2fcccf6 Release v4.5.59 2026-03-13 16:50:14 +00:00
Mervin Praison 1a42ca92d3 Release v4.5.58 2026-03-13 16:30:18 +00:00
Mervin Praison c75d087591 feat(agent): add transparency for tool usage in system prompts
Added a guideline in the system prompt to explain actions before tool calls, enhancing clarity and transparency in interactions. Updated version to 1.5.58 to reflect changes.
2026-03-13 16:27:52 +00:00
Mervin Praison 2f6f496e5d Release v4.5.57 2026-03-13 15:34:30 +00:00
Mervin Praison 42ab69c142 Release v4.5.56 2026-03-13 14:41:14 +00:00
Mervin Praison db62f4368c feat(agent): implement doom recovery mechanism for autonomy
Added a `_doom_recovery_active` flag to manage agent behavior during
doom loops. This prevents normal termination signals when a doom loop
is detected, allowing the system to handle recovery more effectively.
Updated relevant sections in `agent.py` and adjusted unit tests to
reflect changes. Bumped version to 1.5.56.
2026-03-13 14:38:43 +00:00
Mervin Praison fd3daa27fa Release v4.5.55 2026-03-13 14:17:35 +00:00
Mervin Praison 875da3dfe0 feat(agent, llm): enhance agent response handling and tool call logic
Improved handling of agent's chat responses, including better
doom loop detection. Added intermediate narrative emission for LLM
tool calls, enhancing transparency in execution logic.
Updated tests to cover new functionality.
2026-03-13 14:14:00 +00:00
Mervin Praison 620e21b599 test(integration): add 10-step task comparison integration test
This test evaluates the functionality of the tool_completion fix
with a detailed multi-tool prompt. It includes tasks such as
web searching, file creation, execution, and reporting.
2026-03-13 09:18:54 +00:00
Mervin Praison 79ad6b621f Release v4.5.54 2026-03-13 08:35:47 +00:00
github-actions[bot] cbe57c9d52 docs: auto-update api.md [skip ci] 2026-03-13 08:33:42 +00:00
Mervin Praison fa264e1314 feat(agent): enhance tool call completion handling and tracking
- Added completion callback for tool-call completion if the model used tools and produced a substantive response.
- Updated `execute_tool` and `_execute_tool_with_context` methods to include `tool_call_id` for better event correlation.
- Improved logging for tool execution within the agent's framework.
2026-03-13 08:33:24 +00:00
Mervin Praison e71e73cca4 Release v4.5.52 2026-03-13 07:36:38 +00:00
Mervin Praison 9861520be6 Release v4.5.51 2026-03-13 00:26:30 +00:00
Mervin Praison 710624bd69 Release v4.5.49 2026-03-13 00:23:37 +00:00
Mervin Praison a35a6690c4 Release v4.5.48 2026-03-12 16:38:48 +00:00
Mervin Praison 1b9d569de8 Release v4.5.46 2026-03-12 13:59:37 +00:00
Mervin Praison 956e4eeb91 feat(tools): add learning tools for improved user preference management
Introduced `store_learning` and `search_learning` functions to
facilitate categorized storage and retrieval of user insights and
preferences. Enhanced agent interaction by allowing agents to
remember and recollect user behaviors across sessions. Updated
agent state to include a learn manager for integration with the
learning system.
2026-03-12 13:52:36 +00:00
Mervin Praison 654e1e2aec Release v4.5.45 2026-03-12 12:26:17 +00:00
Mervin Praison 2870aa9861 feat(a2a): add authentication support and standalone server feature
- Introduced optional bearer token for POST /a2a requests
- Added serve method for standalone A2A server using FastAPI
- Updated router to enforce authentication when token is set
- Enhanced message parsing to support additional part types
2026-03-12 12:23:31 +00:00
Mervin Praison 7bec22ce77 Release v4.5.44 2026-03-12 12:04:19 +00:00
Mervin Praison 520a46019a feat(docs): enhance user guide with Python examples and CLI reference
Added detailed usage examples for single, multi agents, MCP, custom tools, and persistence. Included a quick reference for CLI commands to improve user onboarding. Removed outdated sections for clarity.
2026-03-12 12:00:48 +00:00
Mervin Praison 7ee1c642d4 docs(README): remove outdated features from the description
Updated the README to eliminate references to specific deployment and connection features that are no longer relevant.
2026-03-12 11:01:41 +00:00
Mervin Praison 397f499dda docs(README): restructure and simplify features section
Updated the "Why PraisonAI?" section for clarity and conciseness,
retaining key features while enabling easier navigation with an
expandable details section to view all features.
2026-03-12 10:56:37 +00:00
Mervin Praison d4dac28434 docs(README): update structure and content for clarity and branding
Modified logo dimensions and enhanced the documentation layout by
refactoring sections related to quick start instructions and added
details on supported LLM providers. Overall goal was to improve
readability and user experience.
2026-03-12 10:53:29 +00:00
Mervin Praison dbca6e0047 feat(docs): update README and add 24 new provider icons
Updated README.md to reflect changes in logo paths, added new features,
and improved contribution guidelines. Added 24 new provider icons to
public directory for better integration visibility.
2026-03-12 10:47:24 +00:00
Mervin Praison ed3da59b2c docs(README): update use cases section and clarify performance benchmarks
Added a new "Use Cases" section outlining practical applications of AI agents
and streamlined the language in the performance benchmarks for clarity.
2026-03-12 10:37:20 +00:00
Mervin Praison 55ab4acf8d docs(README): update feature names and add use cases section
Revised feature names for clarity and consistency, changing "Dashboard UI" to "AgentClaw." Added a new section outlining various use cases for AI agents across multiple industries. Updated links and formatting for better readability.
2026-03-12 10:31:06 +00:00
Mervin Praison e0c1301965 docs(README.md): add example command for using praisonai claw feature
Added a line to the README.md to provide an example command for
installing the claw feature of the praisonai package.
2026-03-12 08:11:14 +00:00
Mervin Praison 79c4002e3c docs(README): clarify section title for PraisonAI Claw
Updated the section title to include "Dashboard UI" for better clarity.
2026-03-12 08:05:33 +00:00
Mervin Praison 2e5a8c5fe5 docs(README): Add section for PraisonAI Claw integration
Included installation instructions and dashboard overview for connecting AI agents to messaging platforms like Telegram, Discord, and WhatsApp.
2026-03-12 08:04:44 +00:00
Mervin Praison 99c811da21 feat(docs): add dashboard image to README
Added a central image of the PraisonAI dashboard to enhance visual appeal
and provide users with a clear reference to the interface.
2026-03-12 01:34:56 +00:00
Mervin Praison 0fb773adcd Release v4.5.43 2026-03-12 00:27:47 +00:00
Mervin Praison d95d3854db feat(dependencies): update praisonai and praisonaiagents to v4.5.43
Updated Dockerfiles and project files to use praisonai version 4.5.43
and praisonaiagents version 1.5.43 for improved functionality and
features. Added new 'claw' command in the CLI for enhanced user
experience.
2026-03-12 00:27:47 +00:00
github-actions[bot] 1383db2fba docs: auto-update api.md [skip ci] 2026-03-12 00:18:34 +00:00
Mervin Praison 75adcfed68 Release v4.5.42 2026-03-12 00:18:16 +00:00
Mervin Praison 3793ef0387 docs(README): update project description for clarity and impact
Updated the project description to highlight key features and capabilities, including automation, supported platforms, and other enhancements for better understanding.
2026-03-11 23:06:42 +00:00
Mervin Praison bf3b7f8e9d docs(README): update feature list with consistent layout and missing details
Adjusted the feature table in README.md for improved readability.
Added missing features and ensured consistent formatting throughout the list.
2026-03-11 22:55:11 +00:00
Mervin Praison a4da8c6ab6 feat(docs): update documentation links and improve clarity
Revised multiple links in the README to point to the correct documentation
pages, specifically changing references from features to concepts to enhance
user navigation and comprehension.
2026-03-11 22:47:54 +00:00
Mervin Praison f59ba1b83d Release v4.5.41 2026-03-11 22:32:27 +00:00
Mervin Praison 8c88766900 feat(docs): update README and versioning for Dashboard UI feature
Added a new entry for the Dashboard UI in the README and updated
the version of `praisonaiagents` to 1.5.41 in the relevant files.
This reflects recent changes and improvements in UI capabilities.
2026-03-11 22:31:09 +00:00
Mervin Praison a6eaa854fe Release v4.5.40 2026-03-11 22:21:06 +00:00
Mervin Praison e36911e712 feat(README, pyproject): update bot features and version number
Updated README.md to highlight new bot functionalities including Telegram,
Discord, Slack, and WhatsApp integration. Increased version to 1.5.40
in `pyproject.toml` for `praisonaiagents`. Expanded dependencies for
claw in the main `pyproject.toml`. Enhanced .gitignore to include
build files.
2026-03-11 21:22:25 +00:00
Mervin Praison c3a6a39ee1 docs(readme): update links to feature documentation in README.md
Replaced outdated links in the README file with new feature documentation
links to ensure users have access to the latest information about the
project's capabilities.
2026-03-11 20:58:02 +00:00
Mervin Praison 15146ff6a7 docs(README): add new features to the documentation table
Added several new features to the README.md including Sessions + Auto-Save, Telemetry, Policy Engine, Thinking Budgets, Background Tasks, Doom Loop Detection, Graph Memory, RAG + Quality-Based RAG, and Sandbox Execution.
2026-03-11 20:55:38 +00:00
Mervin Praison 0d0c2fa762 docs(README): update features section with detailed explanations
Enhanced the features section with specific feature descriptions and
usage examples to provide clearer insights into the framework's
capabilities and benefits for new users.
2026-03-11 20:53:30 +00:00
Mervin Praison c9010fca9f docs(readme): major rewrite of README for clarity and content updates
The README.md has been significantly updated with a focus on clear
guidance and reduced verbosity. Many sections were condensed or
removed, resulting in a net decrease in lines while enhancing
navigation and support links for users.
2026-03-11 20:45:19 +00:00
Mervin Praison 7a36352196 Release v4.5.39 2026-03-11 00:23:03 +00:00
Mervin Praison 6ae4ebedcb Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-03-11 00:20:17 +00:00
Mervin Praison 3377347087 feat(scheduler): add session ID to DeliveryTarget and scheduling commands
Added an optional session_id parameter to DeliveryTarget and scheduling
functions to preserve conversation context during job execution. This
enhancement allows agents to access prior chat history and improves
overall task management flexibility. Updated relevant documentation and
CLI commands accordingly.
2026-03-11 00:19:32 +00:00
github-actions[bot] f47aa248ea docs: auto-update api.md [skip ci] 2026-03-10 18:00:30 +00:00
JiangNan ad8ee02b5a fix: correct duplicate gpt-4o-mini key in MODEL_WINDOWS to gpt-4o
MODEL_WINDOWS had two entries for gpt-4o-mini and none for gpt-4o.
The first entry was clearly intended to be gpt-4o based on context.
This meant gpt-4o users got no context window match, potentially
causing suboptimal chunking or fallback behavior.

Signed-off-by: JiangNan <[email protected]>
2026-03-08 22:32:24 +08:00
JiangNan 02b9c64c0a fix: handle None agent name when generating history session ID
When an agent is created with instructions but no explicit name, and
memory history is enabled, name can be None. Calling .encode() on None
raises AttributeError. Fall back to "agent" when name is None.

Signed-off-by: JiangNan <[email protected]>
2026-03-08 22:32:21 +08:00
114 changed files with 14009 additions and 6564 deletions

No files matched your search

+3 -1
View File
@@ -93,4 +93,6 @@ Cargo.lock
**/*.rs.bk
*.pdb
.windsurf
.agent
.agent
src/praisonai/build
+467 -3093
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -55,7 +55,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments, tool_call_id = None)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
Methods:
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
# Planning
@@ -784,8 +784,10 @@ Methods:
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
@@ -802,6 +804,7 @@ Methods:
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
@@ -868,7 +871,9 @@ Methods:
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
@@ -964,6 +969,7 @@ Methods:
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.38" \
"praisonai>=4.5.73" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+61
View File
@@ -0,0 +1,61 @@
# PraisonAI 🦞 Claw — All-in-one: dashboard + bots + gateway + agents
#
# Build: docker build -f docker/Dockerfile.claw -t praisonai:claw ..
# Run: docker run -p 8082:8082 -e OPENAI_API_KEY=sk-xxx praisonai:claw
# Health: curl http://localhost:8082/health
#
# Override CMD for other modes:
# Bot: docker run -e ... praisonai:claw praisonai bot telegram
# Gateway: docker run -e ... praisonai:claw praisonai gateway start
# Serve: docker run -e ... praisonai:claw praisonai serve agents.yaml
# Shell: docker run --rm -it praisonai:claw bash
FROM python:3.12-slim AS base
# ── Environment ──────────────────────────────────────────────────────────────
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
AIUI_PORT=8082 \
AIUI_HOST=0.0.0.0 \
AIUI_DATA_DIR=/data
WORKDIR /app
# ── System dependencies ─────────────────────────────────────────────────────
# tini: PID 1 signal handling (graceful shutdown on Fly/Railway/k8s)
# curl: HEALTHCHECK probe
# build-essential: compile native Python wheels (chromadb, etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
curl \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# ── Install PraisonAI 🦞 ────────────────────────────────────────────────────
# Single command installs: AIUI dashboard, agents SDK (memory/knowledge/llm/mcp),
# bot channels (Telegram/Discord/Slack), gateway, search tools, and more.
RUN pip install --no-cache-dir "praisonai[claw]"
# ── Data volume ──────────────────────────────────────────────────────────────
# Persistence for sessions, config, memory, knowledge stores
RUN mkdir -p /data
VOLUME /data
# ── Entrypoint ───────────────────────────────────────────────────────────────
# Ensures /data exists and forwards all signals via tini
RUN printf '#!/bin/sh\n\
set -e\n\
mkdir -p /data 2>/dev/null || true\n\
exec "$@"\n' > /usr/local/bin/entrypoint.sh \
&& chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8082
# ── Health check ─────────────────────────────────────────────────────────────
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8082/health || exit 1
# ── Run ──────────────────────────────────────────────────────────────────────
# Default: AIUI dashboard via praisonai claw. Override CMD for bot/gateway/serve.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["praisonai", "claw", "--port", "8082"]
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.38" \
"praisonai>=4.5.73" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.38" \
"praisonai>=4.5.73" \
"praisonai[ui]" \
"praisonai[crewai]"
+25
View File
@@ -1,6 +1,29 @@
version: '3.8'
services:
# PraisonAI 🦞 Claw — Recommended all-in-one service
claw:
build:
context: ..
dockerfile: docker/Dockerfile.claw
volumes:
- claw_data:/data
ports:
- "8082:8082"
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
restart: unless-stopped
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8082/health" ]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
networks:
- praison_network
# ─── Legacy services (kept for backward compatibility) ─────────────────
# PraisonAI UI Service
ui:
build:
@@ -92,6 +115,8 @@ services:
# For bot services (Slack, Discord, Telegram), see docker/bots/
volumes:
claw_data:
driver: local
praison_data:
driver: local
+216
View File
@@ -0,0 +1,216 @@
"""
Email Bot Example — Deploy an AI Agent to Your Mailbox
This example shows how to deploy PraisonAI agents as email bots
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
and replies intelligently using an LLM-powered agent.
Supported providers:
- Gmail (with App Password)
- Outlook/Microsoft 365
- Any IMAP/SMTP server
Environment Variables:
export OPENAI_API_KEY=your_key
export [email protected]
export EMAIL_APP_PASSWORD=your_app_password
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
Usage:
python bot_email_example.py
"""
import asyncio
import os
from praisonaiagents import Agent
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
def example_minimal():
"""Minimal email bot — 3 lines to start."""
from praisonai.bots import Bot
agent = Agent(
name="email_assistant",
instructions="You are a helpful email assistant. Reply concisely and professionally.",
llm="gpt-4o-mini",
)
# Token and server resolved from env vars automatically:
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
bot = Bot("email", agent=agent)
bot.run() # Blocks until Ctrl+C
# ── Example 2: Custom configuration ───────────────────────────────
def example_with_config():
"""Email bot with explicit configuration."""
from praisonai.bots import Bot
from praisonaiagents import BotConfig
config = BotConfig(
polling_interval=30.0, # Check inbox every 30s
allowed_users=[ # Only respond to these senders
"[email protected]",
"[email protected]",
],
max_message_length=10000, # Allow long email replies
session_ttl=86400, # Clear session after 24h inactivity
metadata={
"imap_folder": "INBOX", # Which folder to monitor
},
)
agent = Agent(
name="support_agent",
instructions="""You are a technical support agent for ExampleCo.
Guidelines:
- Be professional and empathetic
- Provide clear step-by-step solutions
- If you don't know the answer, escalate to [email protected]
- Keep replies under 500 words
""",
llm="gpt-4o-mini",
)
bot = Bot("email", agent=agent, config=config)
bot.run()
# ── Example 3: Direct EmailBot with custom handlers ──────────────
def example_direct():
"""Direct EmailBot usage with custom message handler and commands."""
from praisonai.bots import EmailBot
agent = Agent(
name="smart_inbox",
instructions="You help triage and respond to support emails.",
llm="gpt-4o-mini",
)
bot = EmailBot(
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
agent=agent,
email_address=os.environ.get("EMAIL_ADDRESS", ""),
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
)
# Custom message handler — runs on every incoming email
@bot.on_message
async def log_incoming(message):
print(f"📧 New email from: {message.sender.username}")
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
# Custom command — triggered when subject starts with "/status"
@bot.on_command("status")
async def handle_status(message):
await bot.send_message(
channel_id=message.sender.user_id,
content={
"subject": "Re: Bot Status",
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
},
reply_to=message.message_id,
)
asyncio.run(bot.start())
# ── Example 4: Multi-platform with BotOS ─────────────────────────
def example_multi_platform():
"""Run email bot alongside Telegram and Discord bots."""
from praisonai.bots import BotOS, Bot
agent = Agent(
name="omni_assistant",
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
llm="gpt-4o-mini",
)
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
botos.run() # All three bots run concurrently
# ── Example 5: Probe connectivity ─────────────────────────────────
async def example_probe():
"""Test email server connectivity without starting the bot."""
from praisonai.bots import Bot
bot = Bot("email")
result = await bot.probe()
print(f"Connection OK: {result.ok}")
print(f"Platform: {result.platform}")
if result.bot_username:
print(f"Email: {result.bot_username}")
if result.error:
print(f"Error: {result.error}")
print(f"Latency: {result.elapsed_ms:.0f}ms")
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
YAML_EXAMPLE = """
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
agent:
name: email_support
instructions: >
You are a professional support agent.
Reply concisely and helpfully.
llm: gpt-4o-mini
tools:
- search_web
platforms:
email:
token: ${EMAIL_APP_PASSWORD}
"""
# ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
examples = {
"minimal": ("Minimal email bot", example_minimal),
"config": ("With custom config", example_with_config),
"direct": ("Direct EmailBot with handlers", example_direct),
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
}
if len(sys.argv) > 1 and sys.argv[1] in examples:
name = sys.argv[1]
desc, fn = examples[name]
print(f"Running: {desc}")
fn()
else:
print("PraisonAI Email Bot Examples")
print("=" * 40)
print()
print("Usage: python bot_email_example.py <example>")
print()
print("Available examples:")
for key, (desc, _) in examples.items():
print(f" {key:10s} {desc}")
print()
print("Required environment variables:")
print(" OPENAI_API_KEY Your OpenAI API key")
print(" EMAIL_ADDRESS Bot's email address")
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
print()
print("Optional:")
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
print()
print("YAML config example:")
print(YAML_EXAMPLE)
+27
View File
@@ -0,0 +1,27 @@
# PraisonAI AgentMail Bot Configuration (Zero-Code)
#
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
#
# Usage:
# praisonai bot start --config agentmail-bot.yaml
#
# Requires:
# pip install praisonai agentmail
# export AGENTMAIL_API_KEY=am_...
#
# Optional env vars:
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
# Agent configuration
agent:
name: "Email Assistant"
instructions: "You are a helpful email assistant. Keep replies concise and professional."
model: "gpt-4o-mini"
memory: true
# Channel configuration (multi-channel format)
channels:
agentmail:
token: "${AGENTMAIL_API_KEY}"
# Optional: inbox_id, domain are resolved from env vars automatically
@@ -1045,7 +1045,7 @@ class Agent:
session_id = _history_session_id
elif _history_enabled and session_id is None and _history_session_id is None:
import hashlib as _hl
_agent_hash = _hl.md5(name.encode()).hexdigest()[:8]
_agent_hash = _hl.md5((name or "agent").encode()).hexdigest()[:8]
session_id = f"history_{_agent_hash}"
_history_session_id = session_id
@@ -1322,6 +1322,7 @@ class Agent:
llm_config['api_key'] = api_key
llm_config['metrics'] = metrics
self.llm_instance = LLM(**llm_config)
self.llm = llm.get('model', Agent._get_default_model())
else:
# Create LLM with model string and base_url (cached for performance)
model_name = llm or Agent._get_default_model()
@@ -1335,6 +1336,7 @@ class Agent:
prompt_caching=prompt_caching,
claude_memory=claude_memory
)
self.llm = model_name
self._using_custom_llm = True
except ImportError as e:
raise ImportError(
@@ -1354,6 +1356,7 @@ class Agent:
llm['metrics'] = metrics
self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
self._using_custom_llm = True
self.llm = llm.get('model', Agent._get_default_model())
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
@@ -2020,6 +2023,9 @@ Summary:"""
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = False
return
self.autonomy_enabled = True
@@ -2047,12 +2053,16 @@ Summary:"""
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = False
return
# Preserve ALL AutonomyConfig fields in the dict (G14 fix: no lossy extraction)
self.autonomy_config = {
"enabled": config.enabled,
"level": config.level,
"mode": config.mode,
"max_iterations": config.max_iterations,
"doom_loop_threshold": config.doom_loop_threshold,
"auto_escalate": config.auto_escalate,
@@ -2074,6 +2084,7 @@ Summary:"""
self._autonomy_trigger = AutonomyTrigger()
self._doom_loop_tracker = DoomLoopTracker(threshold=config.doom_loop_threshold)
self._doom_recovery_active = False
# Initialize FileSnapshot for filesystem tracking (lazy import)
self._file_snapshot = None
@@ -2434,7 +2445,45 @@ Summary:"""
started_at=started_at,
)
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
# Execute one turn using the agent's chat method
# Always use the original prompt (prompt re-injection)
# Reset per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = 0
try:
response = self.chat(prompt)
except Exception as e:
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
error=str(e),
started_at=started_at,
)
response_str = str(response)
# Record response text for content streaming loop detection
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.record_response(response_str)
# Record the action for doom loop tracking (G1 fix: was missing)
# Use response hash only — iteration was removed because it made
# every fingerprint unique, preventing doom loop detection.
self._record_action(
"chat",
{"response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Check doom loop AFTER recording action so detector sees
# the current iteration's fingerprint (repositioned from top
# of loop for correct detection timing).
if self._is_doom_loop():
recovery = self._get_doom_recovery()
@@ -2456,12 +2505,15 @@ Summary:"""
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = True
continue
elif recovery == "escalate_model":
# Give the agent one more try with explicit error guidance
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = True
continue
elif recovery == "request_help":
return AutonomyResult(
@@ -2486,35 +2538,6 @@ Summary:"""
started_at=started_at,
)
# Execute one turn using the agent's chat method
# Always use the original prompt (prompt re-injection)
try:
response = self.chat(prompt)
except Exception as e:
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
error=str(e),
started_at=started_at,
)
response_str = str(response)
# Record the action for doom loop tracking (G1 fix: was missing)
# Use iteration number + response hash to avoid false positives
# when same prompt is re-injected (G8 fix: doom loop false positive)
self._record_action(
"chat",
{"iteration": iterations, "response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Record for action history
actions_taken.append({
"iteration": iterations,
@@ -2541,6 +2564,30 @@ Summary:"""
# Auto-save session after each iteration (memory integration)
self._auto_save_session()
# ─────────────────────────────────────────────────────────────
# TOOL-CALL COMPLETION: If the model used tools this turn AND
# produced a substantive response, the inner loop completed
# the task naturally (model stopped calling tools = done).
# This is the same signal the CLI/wrapper path trusts.
# ─────────────────────────────────────────────────────────────
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="tool_completion",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
completion_reason="tool_completion",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
if (self.autonomy_config.get("auto_escalate")
and iterations > 1
@@ -2622,6 +2669,34 @@ Summary:"""
# Clear context between iterations if enabled
if effective_clear_context:
self.clear_history()
# No-tool-call termination: if model makes no tool calls for 2+
# consecutive turns, treat as completion signal.
# Skip first iteration (model may be planning).
# Suppressed when doom recovery is active — once a doom loop
# is detected, no_tool_calls (a success exit) should not fire;
# the doom loop system manages termination instead.
if (self._autonomy_turn_tool_count == 0 and iterations > 1
and not self._doom_recovery_active):
self._consecutive_no_tool_turns += 1
if self._consecutive_no_tool_turns >= 2:
execute_sync_callback('autonomy_complete',
completion_reason="no_tool_calls",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
completion_reason="no_tool_calls",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
else:
self._consecutive_no_tool_turns = 0
# Max iterations reached
# P3/G2: Emit completion callback for max iterations
@@ -2767,7 +2842,45 @@ Summary:"""
started_at=started_at,
)
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
# Execute one turn using the agent's async chat method
# Always use the original prompt (prompt re-injection)
# Reset per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = 0
try:
response = await self.achat(prompt)
except Exception as e:
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
error=str(e),
started_at=started_at,
)
response_str = str(response)
# Record response text for content streaming loop detection
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.record_response(response_str)
# Record the action for doom loop tracking (G1 fix: was missing)
# Use response hash only — iteration was removed because it made
# every fingerprint unique, preventing doom loop detection.
self._record_action(
"chat",
{"response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Check doom loop AFTER recording action so detector sees
# the current iteration's fingerprint (repositioned from top
# of loop for correct detection timing).
if self._is_doom_loop():
recovery = self._get_doom_recovery()
obs = getattr(self, '_observability_hooks', None)
@@ -2782,11 +2895,15 @@ Summary:"""
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = True
continue
elif recovery == "escalate_model":
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
self._consecutive_no_tool_turns = 0
self._doom_recovery_active = True
continue
elif recovery == "request_help":
return AutonomyResult(
@@ -2811,35 +2928,6 @@ Summary:"""
started_at=started_at,
)
# Execute one turn using the agent's async chat method
# Always use the original prompt (prompt re-injection)
try:
response = await self.achat(prompt)
except Exception as e:
return AutonomyResult(
success=False,
output=str(e),
completion_reason="error",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
error=str(e),
started_at=started_at,
)
response_str = str(response)
# Record the action for doom loop tracking (G2 fix: was missing)
# Use iteration number + response hash to avoid false positives
# when same prompt is re-injected (G8 fix: doom loop false positive)
self._record_action(
"chat",
{"iteration": iterations, "response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Record for action history
actions_taken.append({
"iteration": iterations,
@@ -2866,6 +2954,23 @@ Summary:"""
# Auto-save session after each async iteration (memory integration)
self._auto_save_session()
# ─────────────────────────────────────────────────────────────
# TOOL-CALL COMPLETION: If the model used tools this turn AND
# produced a substantive response, the inner loop completed
# the task naturally (model stopped calling tools = done).
# ─────────────────────────────────────────────────────────────
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
return AutonomyResult(
success=True,
output=response_str,
completion_reason="tool_completion",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
if (self.autonomy_config.get("auto_escalate")
and iterations > 1
@@ -2922,6 +3027,28 @@ Summary:"""
if effective_clear_context:
self.clear_history()
# No-tool-call termination: if model makes no tool calls for 2+
# consecutive turns, treat as completion signal.
# Suppressed when doom recovery is active — once a doom loop
# is detected, no_tool_calls (a success exit) should not fire;
# the doom loop system manages termination instead.
if (self._autonomy_turn_tool_count == 0 and iterations > 1
and not self._doom_recovery_active):
self._consecutive_no_tool_turns += 1
if self._consecutive_no_tool_turns >= 2:
return AutonomyResult(
success=True,
output=response_str,
completion_reason="no_tool_calls",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
else:
self._consecutive_no_tool_turns = 0
# Yield control to allow other async tasks to run
await asyncio.sleep(0)
@@ -4151,6 +4278,7 @@ Your Goal: {self.goal}"""
if tool_names:
system_prompt += f"\n\nYou have access to the following tools: {', '.join(tool_names)}. Use these tools when appropriate to help complete your tasks. Always use tools when they can help provide accurate information or perform actions."
system_prompt += "\n\nExplain Before Acting: Before calling a tool, provide a brief one-sentence explanation of what you are about to do and why. Skip explanations only for repetitive low-level operations where narration would be noisy. When performing a batch of similar operations (e.g. searching for multiple items), explain the group once rather than narrating each call individually."
# Cache the generated system prompt (only if cache_key is set, i.e., memory not enabled)
# Simple cache size limit to prevent unbounded growth
@@ -4503,10 +4631,16 @@ Your Goal: {self.goal}"""
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
return arguments
def execute_tool(self, function_name, arguments):
def execute_tool(self, function_name, arguments, tool_call_id=None):
"""
Execute a tool dynamically based on the function name and arguments.
Injects agent state for tools with Injected[T] parameters.
Args:
function_name: Name of the tool function to execute
arguments: Dictionary of arguments to pass to the tool
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
Used for correlating TOOL_CALL_START/RESULT stream events
"""
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
@@ -4520,22 +4654,47 @@ Your Goal: {self.goal}"""
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata={'agent_name': self.name}
)
# Execute within injection context
return self._execute_tool_with_context(function_name, arguments, state)
return self._execute_tool_with_context(function_name, arguments, state, tool_call_id)
def _execute_tool_with_context(self, function_name, arguments, state):
"""Execute tool within injection context, with optional output truncation."""
def _execute_tool_with_context(self, function_name, arguments, state, tool_call_id=None):
"""Execute tool within injection context, with optional output truncation.
Args:
function_name: Name of the tool function to execute
arguments: Dictionary of arguments to pass to the tool
state: AgentState for injection context
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
"""
from ..tools.injected import with_injection_context
from ..trace.context_events import get_context_emitter
from ..streaming.events import StreamEvent, StreamEventType
import time as _time
# Emit tool call start event (zero overhead when not set)
_trace_emitter = get_context_emitter()
_trace_emitter.tool_call_start(self.name, function_name, arguments)
_tool_start_time = _time.time()
_tool_start_perf = _time.perf_counter()
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
self.__stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_START,
timestamp=_tool_start_perf,
tool_call={
"name": function_name,
"arguments": arguments, # PARSED DICT, not JSON string
"id": tool_call_id, # Now properly threaded through
},
agent_id=self.name,
))
try:
# Trigger BEFORE_TOOL hook
@@ -4610,6 +4769,24 @@ Your Goal: {self.goal}"""
_duration_ms = (_time.time() - _tool_start_time) * 1000
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
# Truncate result for stream event (keep it reasonable for UI display)
result_summary = str(result)[:500] if result else None
self.__stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
timestamp=_time.perf_counter(),
tool_call={
"name": function_name,
"arguments": arguments,
"result": result_summary,
"id": tool_call_id, # Now properly threaded through
},
agent_id=self.name,
metadata={"duration_ms": _duration_ms},
))
# Trigger AFTER_TOOL hook
from ..hooks import HookEvent, AfterToolInput
after_tool_input = AfterToolInput(
@@ -4632,6 +4809,9 @@ Your Goal: {self.goal}"""
if not is_error:
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
# Increment per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
return result
except Exception as e:
# Emit tool call end with error
@@ -5542,7 +5722,7 @@ Your Goal: {self.goal}"""
from datetime import datetime, timezone
# Per-hour session ID: YYYYMMDDHH (UTC) + agent name hash for uniqueness
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
agent_hash = hashlib.md5(self.name.encode()).hexdigest()[:6]
agent_hash = hashlib.md5((self.name or "agent").encode()).hexdigest()[:6]
self._session_id = f"{hour_str}-{agent_hash}"
# Call db adapter's on_agent_start to get previous messages
@@ -6023,6 +6203,7 @@ Your Goal: {self.goal}"""
use_native_format=use_native_format
)
# Store chat history length for potential rollback
chat_history_length = len(self.chat_history)
@@ -6055,6 +6236,7 @@ Your Goal: {self.goal}"""
# Use processed messages for the LLM call
messages = processed_messages
# Wrap entire while loop in try-except for rollback on any failure
try:
while True:
@@ -6742,23 +6924,49 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# This allows: Agent(autonomy=True) + await agent.astart("Task") to just work!
# ─────────────────────────────────────────────────────────────────────
if self.autonomy_enabled:
# Extract autonomy-specific kwargs
timeout = kwargs.pop('timeout', None)
kwargs.pop('stream', None) # Not used in autonomous mode
# Get config values from autonomy_config
auto_config = self.autonomy_config or {}
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
mode = auto_config.get('mode', 'caller')
return await self.run_autonomous_async(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
if mode == 'iterative':
# Iterative mode: use run_autonomous_async (backward compat for full_auto)
timeout = kwargs.pop('timeout', None)
kwargs.pop('stream', None) # Not used in autonomous mode
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
return await self.run_autonomous_async(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
else:
# Caller mode: single achat() call, wrapper-equivalent
import time as time_module
from datetime import datetime, timezone
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
response = await self.achat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat
self._auto_save_session()
# Wrap in AutonomyResult for consistent API
from .autonomy import AutonomyResult
return AutonomyResult(
success=True,
output=response_str,
completion_reason="caller_mode",
iterations=1,
stage="direct",
actions=[],
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Determine streaming behavior (same logic as start())
stream_requested = kwargs.get('stream')
@@ -7011,22 +7219,54 @@ Write the complete compiled report:"""
# This allows: Agent(autonomy=True) + agent.start("Task") to just work!
# ─────────────────────────────────────────────────────────────────────
if self.autonomy_enabled:
# Extract autonomy-specific kwargs
timeout = kwargs.pop('timeout', None)
# Get config values from autonomy_config
auto_config = self.autonomy_config or {}
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
mode = auto_config.get('mode', 'caller')
return self.run_autonomous(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
if mode == 'iterative':
# Iterative mode: use run_autonomous (backward compat for full_auto)
timeout = kwargs.pop('timeout', None)
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
return self.run_autonomous(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
else:
# Caller mode: single chat() call, wrapper-equivalent
# All init-time features (approval, doom-loop per-tool,
# track_changes, sandbox, default_tools) are already wired.
import time as time_module
from datetime import datetime, timezone
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# Load history from past sessions
self._load_history_context()
# Use chat() — all tools, approval, doom-loop per-tool work
response = self.chat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat
self._auto_save_session()
# Wrap in AutonomyResult for consistent API
from .autonomy import AutonomyResult
return AutonomyResult(
success=True,
output=response_str,
completion_reason="caller_mode",
iterations=1,
stage="direct",
actions=[],
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Load history from past sessions
self._load_history_context()
@@ -7743,7 +7983,8 @@ Write the complete compiled report:"""
tool_result = self.execute_tool(
tool_call['function']['name'],
parsed_args
parsed_args,
tool_call_id=tool_call.get('id')
)
# Add tool result to chat history
self.chat_history.append({
@@ -73,6 +73,17 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
# Valid autonomy modes
VALID_AUTONOMY_MODES = {"caller", "iterative"}
# Smart default mode based on level
_LEVEL_TO_DEFAULT_MODE = {
"suggest": "caller",
"auto_edit": "caller",
"full_auto": "iterative",
}
@dataclass
class AutonomyConfig:
"""Configuration for Agent autonomy features.
@@ -83,6 +94,10 @@ class AutonomyConfig:
Attributes:
enabled: Whether autonomy is enabled
level: Autonomy level (suggest, auto_edit, full_auto)
mode: Execution mode — "caller" (single chat call, wrapper-equivalent)
or "iterative" (internal loop with completion detection).
Defaults based on level: suggest/auto_edit → "caller",
full_auto → "iterative".
max_iterations: Maximum iterations before stopping
doom_loop_threshold: Number of repeated actions to trigger doom loop
auto_escalate: Whether to automatically escalate complexity
@@ -99,18 +114,21 @@ class AutonomyConfig:
Usage::
# Simple — full_auto auto-enables tracking + sandbox
# Simple — default caller mode (wrapper-equivalent, no wasteful loop)
Agent(autonomy=True) # level=suggest, mode=caller
# Full auto — iterative loop with completion detection
Agent(autonomy="full_auto")
# Advanced — explicit config
# Ralph Loop — explicit iterative mode with completion promise
Agent(autonomy=AutonomyConfig(
level="full_auto",
track_changes=True,
sandbox=SandboxConfig.native(writable_paths=["./src"]),
mode="iterative",
completion_promise="COMPLETE",
))
"""
enabled: bool = True
level: str = "suggest"
mode: Optional[str] = None # None = smart default based on level
max_iterations: int = 20
doom_loop_threshold: int = 3
auto_escalate: bool = True
@@ -131,6 +149,14 @@ class AutonomyConfig:
f"Invalid autonomy level: {self.level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
# Smart default mode based on level
if self.mode is None:
self.mode = _LEVEL_TO_DEFAULT_MODE.get(self.level, "caller")
elif self.mode not in VALID_AUTONOMY_MODES:
raise ValueError(
f"Invalid autonomy mode: {self.mode!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_MODES)}"
)
# Auto-enable track_changes for full_auto if not explicitly set
if self.track_changes is None:
self.track_changes = (self.level == "full_auto")
@@ -156,6 +182,7 @@ class AutonomyConfig:
return cls(
enabled=data.get("enabled", True),
level=level,
mode=data.get("mode"), # None = smart default in __post_init__
max_iterations=data.get("max_iterations", 20),
doom_loop_threshold=data.get("doom_loop_threshold", 3),
auto_escalate=data.get("auto_escalate", True),
@@ -378,6 +405,14 @@ class DoomLoopTracker:
"""
self._delegate.mark_progress(marker)
def record_response(self, text: str) -> None:
"""Record model response text for content streaming loop detection.
Args:
text: The model's response text
"""
self._delegate.record_response(text)
def reset(self) -> None:
"""Reset the tracker completely."""
self._delegate.start_session()
@@ -319,3 +319,106 @@ class BackgroundRunner:
except asyncio.CancelledError:
pass
self._cleanup_task = None
# ── Sync-friendly wrappers ──────────────────────────────────────────
def submit_sync(
self,
func: Callable,
args: tuple = (),
kwargs: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
timeout: Optional[float] = None,
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
) -> BackgroundTask:
"""Submit a task from synchronous code.
Creates a background event loop (once) and schedules the task
there. Returns the ``BackgroundTask`` immediately so callers
can poll ``task.status`` / ``task.result`` without blocking.
Args:
func: Function to execute (sync or async)
args: Positional arguments for *func*
kwargs: Keyword arguments for *func*
name: Optional human-readable task name
timeout: Optional timeout in seconds
on_complete: Callback when task completes
Returns:
BackgroundTask for tracking
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(
self.submit(
func, args=args, kwargs=kwargs,
name=name, timeout=timeout, on_complete=on_complete,
),
loop,
)
# Block briefly to get the BackgroundTask object (not the task result).
return future.result(timeout=5)
def submit_agent_sync(
self,
agent: Any,
prompt: str,
name: Optional[str] = None,
timeout: Optional[float] = None,
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
) -> BackgroundTask:
"""Submit an agent task from synchronous code.
Convenience wrapper around :meth:`submit_sync` that resolves
the agent's callable automatically.
Args:
agent: Agent instance (must have ``start``, ``chat``, or ``run``)
prompt: Prompt to send to the agent
name: Optional task name
timeout: Optional timeout in seconds
on_complete: Callback when task completes
Returns:
BackgroundTask for tracking
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(
self.submit_agent(
agent, prompt,
name=name, timeout=timeout, on_complete=on_complete,
),
loop,
)
return future.result(timeout=5)
# ── Module-level background event loop (lazy, daemon) ───────────────────
import threading as _threading
_bg_loop: Optional[asyncio.AbstractEventLoop] = None
_bg_lock = _threading.Lock()
def _get_bg_loop() -> asyncio.AbstractEventLoop:
"""Return a running event loop on a background daemon thread.
Created once on first call; reused thereafter. The thread is a
daemon so it won't prevent process exit.
"""
global _bg_loop
if _bg_loop is not None and _bg_loop.is_running():
return _bg_loop
with _bg_lock:
# Double-check after acquiring lock.
if _bg_loop is not None and _bg_loop.is_running():
return _bg_loop
_bg_loop = asyncio.new_event_loop()
t = _threading.Thread(target=_bg_loop.run_forever, daemon=True)
t.start()
return _bg_loop
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
ChatCommandProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .protocols import (
BotProtocol,
@@ -37,6 +39,8 @@ from .protocols import (
BotOSProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .config import BotConfig, BotOSConfig
@@ -56,4 +60,6 @@ __all__ = [
"BotOSConfig",
"ProbeResult",
"HealthResult",
"EmailProtocol",
"EmailInbox",
]
@@ -29,8 +29,10 @@ class BotConfig:
"""
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
webhook_url: Optional[str] = None
webhook_path: str = "/webhook"
webhook_port: int = 8080
polling_interval: float = 1.0
allowed_users: List[str] = field(default_factory=list)
allowed_channels: List[str] = field(default_factory=list)
@@ -47,7 +49,12 @@ class BotConfig:
group_policy: str = "mention_only" # respond_all, mention_only, command_only
# Default tools enabled for all bots
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
default_tools: List[str] = field(default_factory=lambda: [
"execute_command", "search_web", "web_crawl",
"schedule_add", "schedule_list", "schedule_remove",
"store_memory", "search_memory",
"store_learning", "search_learning",
])
# Auto-approve tool calls (useful for trusted environments)
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
@@ -72,8 +79,10 @@ class BotConfig:
"""Convert to dictionary (hides sensitive data)."""
return {
"token": "***" if self.token else None,
"mode": self.mode,
"webhook_url": self.webhook_url,
"webhook_path": self.webhook_path,
"webhook_port": self.webhook_port,
"polling_interval": self.polling_interval,
"allowed_users": self.allowed_users,
"allowed_channels": self.allowed_channels,
@@ -97,7 +106,17 @@ class BotConfig:
@property
def is_webhook_mode(self) -> bool:
"""Whether bot is configured for webhook mode."""
return bool(self.webhook_url)
return self.mode == "webhook" or bool(self.webhook_url)
@property
def is_ws_mode(self) -> bool:
"""Whether bot is configured for WebSocket mode."""
return self.mode == "ws"
@property
def is_hybrid_mode(self) -> bool:
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
return self.mode == "hybrid"
def is_user_allowed(self, user_id: str) -> bool:
"""Check if a user is allowed to interact with the bot."""
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@dataclass
class EmailInbox:
"""Information about an email inbox.
Attributes:
id: Unique inbox identifier
email_address: The inbox's email address
domain: Domain of the inbox
created_at: When the inbox was created (ISO format)
"""
id: str
email_address: str
domain: str = ""
created_at: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"email_address": self.email_address,
"domain": self.domain,
"created_at": self.created_at,
}
@runtime_checkable
class EmailProtocol(Protocol):
"""Protocol for email bots with inbox lifecycle management.
Extends BotProtocol with email-specific capabilities like
programmatic inbox creation, listing, and deletion.
This protocol is optional — only AgentMail-style bots that
support API-based inbox management need to implement it.
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
Example:
# Check if a bot supports inbox management
if isinstance(bot, EmailProtocol):
inbox = await bot.create_inbox(domain="mycompany.com")
print(f"Created: {inbox['email_address']}")
"""
@property
def email_address(self) -> Optional[str]:
"""The bot's current email address."""
...
async def create_inbox(
self,
domain: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a new email inbox programmatically.
Args:
domain: Optional custom domain for the inbox
**kwargs: Additional provider-specific parameters
Returns:
Dict with at least 'id' and 'email_address' keys
"""
...
async def list_inboxes(self) -> List[Dict[str, Any]]:
"""List all inboxes accessible to this bot.
Returns:
List of inbox dicts with 'id' and 'email_address' keys
"""
...
async def delete_inbox(self, inbox_id: str) -> bool:
"""Delete an inbox.
Args:
inbox_id: ID of the inbox to delete
Returns:
True if deleted successfully
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BotOS Protocol — multi-platform bot orchestration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -10,13 +10,25 @@ from .models import BudgetAllocation
# Model token limits (context window sizes)
MODEL_LIMITS: Dict[str, int] = {
# OpenAI
# OpenAI — gpt-5 family (1M context window)
"gpt-5": 1047576,
"gpt-5-mini": 1047576,
"gpt-5-nano": 1047576,
# OpenAI — gpt-4.1 family (1M context window)
"gpt-4.1": 1047576,
"gpt-4.1-mini": 1047576,
"gpt-4.1-nano": 1047576,
# OpenAI — gpt-4o family
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"gpt-4-turbo": 128000,
"gpt-4": 8192,
"gpt-3.5-turbo": 16385,
"gpt-3.5-turbo-16k": 16385,
# OpenAI — o-series
"o3-mini": 200000,
"o3": 200000,
"o4-mini": 200000,
# Anthropic
"claude-3-5-sonnet": 200000,
"claude-3-5-haiku": 200000,
@@ -33,11 +45,15 @@ MODEL_LIMITS: Dict[str, int] = {
# Default output reserves by model family
OUTPUT_RESERVES: Dict[str, int] = {
"gpt-5": 32768,
"gpt-4.1": 32768,
"gpt-4o": 16384,
"gpt-4o-mini": 16384,
"gpt-4-turbo": 4096,
"gpt-4": 4096,
"gpt-3.5-turbo": 4096,
"o3": 100000,
"o4-mini": 100000,
"claude-3": 8192,
"gemini": 8192,
"default": 8000,
@@ -99,7 +99,6 @@ def test_db_hook_lifecycle():
instructions="Test agent",
db=mock_db,
session_id="test-session-001",
verbose=False
)
# Verify db is stored
@@ -125,7 +124,6 @@ def test_db_none_unchanged_behavior():
agent = Agent(
name="TestAgent",
instructions="Test agent",
verbose=False
)
# Verify db is None
@@ -157,7 +155,6 @@ def test_session_id_auto_generation():
instructions="Test agent",
db=mock_db,
# No session_id provided
verbose=False
)
# Trigger db initialization
@@ -22,6 +22,7 @@ class DoomLoopType(Enum):
NO_PROGRESS = "no_progress" # No meaningful progress
CIRCULAR_PLAN = "circular_plan" # Plan loops back to start
RESOURCE_EXHAUSTION = "resource_exhaustion" # Budget exceeded
REPEATED_OUTPUT = "repeated_output" # Same output text repeated (content chanting)
class RecoveryAction(Enum):
@@ -58,6 +59,10 @@ class DoomLoopConfig:
initial_backoff: float = 1.0 # Initial backoff in seconds
backoff_multiplier: float = 2.0 # Backoff multiplier
max_backoff: float = 30.0 # Maximum backoff
# Content streaming detection
max_repeated_chunks: int = 8 # Max identical output chunks before flagging
content_chunk_size: int = 50 # Sliding window chunk size (chars)
@dataclass
@@ -116,6 +121,7 @@ class DoomLoopDetector:
self._start_time: Optional[float] = None
self._progress_markers: List[str] = []
self._current_backoff: float = self.config.initial_backoff
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
def start_session(self):
"""Start a new detection session."""
@@ -125,6 +131,7 @@ class DoomLoopDetector:
self._recovery_attempts = 0
self._progress_markers.clear()
self._current_backoff = self.config.initial_backoff
self._content_chunk_counts.clear()
def record_action(
self,
@@ -162,6 +169,25 @@ class DoomLoopDetector:
if self.is_doom_loop():
self._handle_loop_detection()
def record_response(self, text: str):
"""
Record model response text for content streaming loop detection.
Hashes sliding window chunks and tracks repetition counts.
Inspired by gemini-cli's sliding window approach.
Args:
text: The model's response text
"""
if not text or len(text) < self.config.content_chunk_size:
return
chunk_size = self.config.content_chunk_size
for i in range(0, len(text) - chunk_size + 1, chunk_size):
chunk = text[i:i + chunk_size]
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
def mark_progress(self, marker: str):
"""
Mark meaningful progress.
@@ -178,6 +204,10 @@ class DoomLoopDetector:
Returns:
True if doom loop detected
"""
# Content streaming loop can occur even without recorded actions
if self._check_content_loop():
return True
if len(self._actions) < 2:
return False
@@ -201,6 +231,10 @@ class DoomLoopDetector:
if self._check_resource_exhaustion():
return True
# Check for content streaming loops (repeated output text)
if self._check_content_loop():
return True
return False
def get_loop_type(self) -> Optional[DoomLoopType]:
@@ -215,6 +249,8 @@ class DoomLoopDetector:
return DoomLoopType.NO_PROGRESS
if self._check_resource_exhaustion():
return DoomLoopType.RESOURCE_EXHAUSTION
if self._check_content_loop():
return DoomLoopType.REPEATED_OUTPUT
return None
def get_loop_event(self) -> Optional[DoomLoopEvent]:
@@ -371,6 +407,19 @@ class DoomLoopDetector:
elapsed = time.time() - self._start_time
return elapsed > self.config.max_total_time
def _check_content_loop(self) -> bool:
"""Check for repeated output text (content streaming loop).
Uses sliding window chunk hashing. If any chunk hash appears
more than max_repeated_chunks times, it indicates the model
is generating repetitive output (chanting).
"""
if not self._content_chunk_counts:
return False
threshold = self.config.max_repeated_chunks
return any(count >= threshold for count in self._content_chunk_counts.values())
def _determine_recovery_action(self, loop_type: DoomLoopType) -> RecoveryAction:
"""Determine appropriate recovery action."""
# If max recovery attempts reached, abort
@@ -408,6 +457,9 @@ class DoomLoopDetector:
DoomLoopType.RESOURCE_EXHAUSTION: (
f"Exceeded time limit of {self.config.max_total_time}s"
),
DoomLoopType.REPEATED_OUTPUT: (
f"Model output repeating — {self.config.max_repeated_chunks}+ identical chunks detected"
),
}
return descriptions.get(loop_type, "Unknown loop type")
+829 -15
View File
@@ -107,7 +107,7 @@ class LLM:
MODEL_WINDOWS = {
# OpenAI
"gpt-4": 6144, # 8,192 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4o": 96000, # 128,000 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4-turbo": 96000, # 128,000 actual
"o1-preview": 96000, # 128,000 actual
@@ -1685,6 +1685,167 @@ Now provide your final answer using this result. Summarize the information natur
# Initial callback is in agent._chat_completion, so this triggers for all loop iterations
_get_display_functions()['execute_sync_callback']('llm_start', model=self.model, agent_name=agent_name)
# ── Responses API path ──────────────────────────────────
# OpenAI models use the Responses API which returns text
# and tool calls as separate output items — no content:null.
if self._supports_responses_api():
responses_params = self._build_responses_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
output_json=output_json,
output_pydantic=output_pydantic,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
)
if stream:
response_text, tool_calls = self._stream_responses_api(
responses_params,
verbose=verbose,
stream_callback=stream_callback,
emit_events=emit_events,
current_time=current_time,
)
else:
resp = self._call_responses_api(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
# Track token usage
if self.metrics and hasattr(resp, 'usage') and resp.usage:
usage = resp.usage
tokens_in = getattr(usage, 'input_tokens', 0) or 0
tokens_out = getattr(usage, 'output_tokens', 0) or 0
# Emit llm_end with metrics
llm_latency_ms = (time.time() - current_time) * 1000
_get_display_functions()['execute_sync_callback'](
'llm_end', model=self.model,
tokens_in=tokens_in, tokens_out=tokens_out,
cost=None, latency_ms=llm_latency_ms,
)
# Emit reasoning if present
if _emit and _reasoning:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=_reasoning, is_reasoning=True,
))
# Emit text content as stream event
if _emit and response_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=response_text, is_reasoning=False,
))
# Build a Chat-Completions-compatible final_response
# so all downstream code (display, callbacks) works unchanged
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls,
}
}]
}
# Display / callback handling (same as Chat Completions path)
if verbose and not interaction_displayed:
_get_display_functions()['display_interaction'](
original_prompt, response_text,
markdown=markdown,
generation_time=time.time() - current_time,
console=self.console,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
interaction_displayed = True
callback_executed = True
elif not callback_executed:
_get_display_functions()['execute_sync_callback'](
'interaction',
message=original_prompt, response=response_text,
markdown=markdown,
generation_time=time.time() - current_time,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
callback_executed = True
# Emit llm_content for intermediate narrative display
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# ── Handle tool calls ───────────────────────────────
if tool_calls and execute_tool_fn:
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
messages.append({
"role": "assistant",
"content": response_text,
"tool_calls": serializable_tool_calls,
})
tool_results = []
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
tool_results.append(tool_result)
accumulated_tool_results.append(tool_result)
if verbose:
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
_get_display_functions()['display_tool_call'](display_message, console=self.console)
result_str = json.dumps(tool_result) if tool_result else "empty"
_get_display_functions()['execute_sync_callback'](
'tool_call',
message=f"Calling function: {function_name}",
tool_name=function_name,
tool_input=arguments,
tool_output=result_str[:200] if result_str else None,
)
# Append tool result as standard tool message
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
# Safety: break after 5 iterations
if iteration_count >= 5:
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
response_text = ""
continue
else:
# No tool calls — we have a final answer
if iteration_count > 0:
final_response_text = response_text.strip() if response_text else ""
else:
final_response_text = response_text.strip() if response_text else ""
break
# ── Chat Completions path (unchanged) ───────────────────
# If reasoning_steps is True, do a single non-streaming call
if reasoning_steps:
resp = litellm.completion(
@@ -2069,18 +2230,59 @@ Now provide your final answer using this result. Summarize the information natur
}]
}
else:
# For non-streaming + non-verbose: no display_generating (per user requirements)
final_response = litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
# For non-streaming + non-verbose path
# When editor output is enabled, use silent streaming
# to capture intermediate text (OpenAI returns content:null
# on tool-call turns in non-streaming mode, but streaming
# delivers text tokens before tool call tokens).
_use_silent_stream = False
try:
from ..output.editor import is_editor_output_enabled
_use_silent_stream = is_editor_output_enabled()
except Exception:
pass
if _use_silent_stream:
# Silent streaming: no display, just accumulate
response_text = ""
tool_calls = []
for chunk in litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=True,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
):
if chunk and chunk.choices and chunk.choices[0].delta:
delta = chunk.choices[0].delta
response_text, tool_calls = self._process_stream_delta(
delta, response_text, tool_calls, formatted_tools
)
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls if tool_calls else None
}
}]
}
else:
# Standard non-streaming path
final_response = litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
)
)
# Handle None content from Gemini
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
@@ -2271,6 +2473,16 @@ Now provide your final answer using this result. Summarize the information natur
# Reset repair count on successful tool call
self._current_repair_count = 0
# Emit intermediate LLM narrative for display
# This fires for ALL responses with text, not just tool-call turns.
# Competitors (Gemini CLI, Codex, OpenCode) display this inline.
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# Handle tool calls - Sequential tool calling logic
if tool_calls and execute_tool_fn:
# Convert tool_calls to a serializable format for all providers
@@ -2313,7 +2525,7 @@ Now provide your final answer using this result. Summarize the information natur
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
tool_result = execute_tool_fn(function_name, 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_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -2959,6 +3171,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Non-recoverable stream iterator error: {iterator_error}")
raise iterator_error
# Emit intermediate LLM narrative for display
# (parity with non-streaming path above)
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# After streaming completes, handle tool calls if present
if tool_calls and execute_tool_fn:
# Add assistant message with tool calls to conversation
@@ -2981,8 +3202,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
try:
# Execute the tool
tool_result = execute_tool_fn(function_name, arguments)
# Execute the tool (pass tool_call_id for event correlation)
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
# Add tool result to messages
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
@@ -3197,6 +3418,104 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
reasoning_content = None
tool_calls = []
# ── Responses API path (async) ──────────────────────────
if self._supports_responses_api():
responses_params = self._build_responses_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
output_json=output_json,
output_pydantic=output_pydantic,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
)
current_time = time.time()
if stream:
response_text, tool_calls = await self._stream_responses_api_async(
responses_params,
stream_callback=kwargs.get('stream_callback'),
emit_events=kwargs.get('emit_events', False),
)
else:
resp = await self._call_responses_api_async(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
# Build Chat-Completions-compatible final_response
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls,
}
}]
}
# Display / callback handling
if verbose and not interaction_displayed:
_get_display_functions()['display_interaction'](
original_prompt, response_text,
markdown=markdown,
generation_time=time.time() - current_time,
console=self.console,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
interaction_displayed = True
callback_executed = True
# Emit llm_content for narrative display
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# ── Handle tool calls ────────────────────────────
if tool_calls and execute_tool_fn:
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
messages.append({
"role": "assistant",
"content": response_text,
"tool_calls": serializable_tool_calls,
})
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}")
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
accumulated_tool_results.append(tool_result)
if verbose:
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
_get_display_functions()['display_tool_call'](display_message, console=self.console)
# Append tool result
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
if iteration_count >= 5:
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
continue
else:
final_response_text = response_text.strip() if response_text else ""
break
# ── Chat Completions path (unchanged) ───────────────
if reasoning_steps and iteration_count == 0:
# Non-streaming call to capture reasoning
resp = await litellm.acompletion(
@@ -4069,6 +4388,501 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return params
# ── Responses API support ───────────────────────────────────────────
# These methods add a Responses API call path alongside the existing
# Chat Completions path. Model detection gates which path is used.
def _supports_responses_api(self) -> bool:
"""
Check if the current model supports the OpenAI Responses API.
Returns True for OpenAI models that support the Responses API
(gpt-4o, gpt-4-turbo, o1, o3, chatgpt-4o, etc.).
Returns False for Anthropic, Gemini, Ollama, local, and all other models.
"""
if not self.model:
return False
model_lower = self.model.lower()
# Exclude non-OpenAI providers that happen to use similar model names
if self._is_ollama_provider():
return False
if self._is_gemini_model():
return False
if self._is_anthropic_model():
return False
# Explicit prefix check for OpenAI & Azure OpenAI
# Note: Azure models often start with the deployment name, but LiteLLM
# normalises them to "azure/<model>" – the prefixes below handle both.
_openai_prefixes = (
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
"o1", "o3", "o4",
"chatgpt-4o",
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
"azure/o1", "azure/o3", "azure/o4",
)
# Match on prefix (gpt-4o, gpt-4o-mini, gpt-4o-2024-08-06, etc.)
for prefix in _openai_prefixes:
if model_lower.startswith(prefix):
return True
# Any model routed through openai/ prefix
if model_lower.startswith("openai/"):
remainder = model_lower[len("openai/"):]
for prefix in _openai_prefixes:
if remainder.startswith(prefix):
return True
return False
def _build_responses_params(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict]] = None,
temperature: Optional[float] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Build parameters for ``litellm.responses()`` from the Chat Completions-
style message list that PraisonAI already constructs.
Mapping:
- system message → ``instructions``
- remaining messages → ``input`` (list of role/content items)
- tools → ``tools`` (Responses API uses same function schema)
- temperature, max_tokens, etc. → direct pass-through
"""
params: Dict[str, Any] = {"model": self.model}
# ── Extract system instructions ─────────────────────────────────
instructions = None
input_items: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "")
if role in ("system", "developer"):
# Accumulate system / developer messages as instructions
content = msg.get("content", "")
if instructions is None:
instructions = content
else:
instructions += "\n" + content
else:
# user / assistant / tool messages become input items
# Special handling for Chat Completions→Responses API format:
if role == "assistant" and msg.get("tool_calls"):
# Assistant message with tool_calls → emit text (if any)
# then emit function_call items for each tool call
content = msg.get("content")
if content and content.strip():
input_items.append({"role": "assistant", "content": content})
for tc in msg["tool_calls"]:
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
input_items.append({
"type": "function_call",
"call_id": tc_id,
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
})
elif role == "tool":
# Tool result → function_call_output
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": msg.get("content", ""),
})
else:
input_items.append(msg)
if instructions:
params["instructions"] = instructions
params["input"] = input_items
# ── Tools ───────────────────────────────────────────────────────
if tools:
# Responses API tool format differs from Chat Completions:
# Chat Completions: {"type": "function", "function": {"name": ..., "parameters": ...}}
# Responses API: {"type": "function", "name": ..., "parameters": ...}
# Transform Chat Completions format → Responses API format
responses_tools = []
for tool in tools:
if isinstance(tool, dict) and "function" in tool:
fn = tool["function"]
responses_tool = {
"type": "function",
"name": fn.get("name", ""),
}
if "description" in fn:
responses_tool["description"] = fn["description"]
if "parameters" in fn:
responses_tool["parameters"] = fn["parameters"]
if fn.get("strict") is not None:
responses_tool["strict"] = fn["strict"]
responses_tools.append(responses_tool)
else:
# Already in Responses API format or unknown — pass through
responses_tools.append(tool)
params["tools"] = responses_tools
if "tool_choice" not in kwargs:
params["tool_choice"] = "auto"
# ── Scalar params ───────────────────────────────────────────────
if temperature is not None:
params["temperature"] = temperature
if self.max_tokens:
params["max_output_tokens"] = self.max_tokens
if self.top_p:
params["top_p"] = self.top_p
if self.base_url:
params["base_url"] = self.base_url
if self.api_key:
params["api_key"] = self.api_key
if self.timeout:
params["timeout"] = self.timeout
# ── Structured output ───────────────────────────────────────────
output_json = kwargs.pop("output_json", None)
output_pydantic = kwargs.pop("output_pydantic", None)
schema_model = output_json or output_pydantic
if schema_model:
from .model_capabilities import supports_structured_outputs
if supports_structured_outputs(self.model):
if hasattr(schema_model, 'model_json_schema'):
schema = schema_model.model_json_schema()
params['text'] = {
"format": {
"type": "json_schema",
"name": getattr(schema_model, '__name__', 'response'),
"schema": schema,
"strict": True,
}
}
# ── Pass through remaining kwargs (stream, etc.) ────────────────
# Filter out internal PraisonAI params that are not Responses API params
_internal = {
'output_json', 'output_pydantic',
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm',
'verbose', 'markdown',
'agent_name', 'agent_role', 'agent_tools',
'task_name', 'task_description', 'task_id',
'execute_tool_fn', 'stream_callback', 'emit_events',
'console', 'reasoning_steps',
}
for k, v in kwargs.items():
if k not in _internal and v is not None:
params[k] = v
return params
def _call_responses_api(self, **params) -> Any:
"""
Call ``litellm.responses()`` synchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
return self._call_with_retry(litellm.responses, **params)
async def _call_responses_api_async(self, **params) -> Any:
"""
Call ``litellm.aresponses()`` asynchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
return await self._call_with_retry_async(litellm.aresponses, **params)
def _extract_from_responses_output(self, response) -> tuple:
"""
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
reasoning_content) tuple that the Chat Completions path produces.
The Responses API returns an ``output`` list where:
- items with type="message" contain text in content[].text
- items with type="function_call" contain tool call info
Unlike Chat Completions, text and tool calls are *always* separate
items, so ``content`` is never null when text is present.
"""
response_text = ""
tool_calls: List[Dict[str, Any]] = []
reasoning_content = None
output_items = getattr(response, 'output', None) or []
for item in output_items:
# Handle both dict and object access
if isinstance(item, dict):
item_type = item.get("type", "")
else:
item_type = getattr(item, "type", "")
if item_type == "message":
# Extract text from content blocks
if isinstance(item, dict):
content_list = item.get("content", [])
else:
content_list = getattr(item, "content", [])
for content_block in content_list:
if isinstance(content_block, dict):
block_type = content_block.get("type", "")
block_text = content_block.get("text", "") or ""
else:
block_type = getattr(content_block, "type", "")
block_text = getattr(content_block, "text", "") or ""
if block_type == "output_text":
response_text += block_text
elif block_type == "reasoning":
reasoning_content = (reasoning_content or "") + block_text
elif item_type == "function_call":
# Extract tool call information
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls)}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "{}")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls)}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "{}")
tool_calls.append({
"id": call_id,
"type": "function",
"function": {
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
}
})
elif item_type == "reasoning":
# Some models return top-level reasoning items
if isinstance(item, dict):
reasoning_text = item.get("summary", "") or item.get("text", "")
else:
reasoning_text = getattr(item, "summary", "") or getattr(item, "text", "")
if reasoning_text:
reasoning_content = (reasoning_content or "") + str(reasoning_text)
return response_text, tool_calls if tool_calls else None, reasoning_content
def _stream_responses_api(
self,
params: Dict[str, Any],
verbose: bool = True,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
current_time: Optional[float] = None,
) -> tuple:
"""
Stream a Responses API call, accumulating text and tool calls.
Returns the same (response_text, tool_calls) tuple as the Chat
Completions streaming path. Emits StreamEvents when configured.
"""
import litellm
params["stream"] = True
response_text = ""
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
for event in litellm.responses(**params):
# Access event type — handle both object and dict
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
# ── Text delta ──────────────────────────────────────────
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
# ── Reasoning delta ─────────────────────────────────────
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
# ── Function call arguments delta ───────────────────────
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {
"id": item_id,
"name": "",
"arguments": "",
}
_pending_tools[idx]["arguments"] += delta_args
# ── Output item done — finalise tool call metadata ──────
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
# Prefer the accumulated delta args if available, else use final
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {
"id": call_id,
"name": fn_name,
"arguments": fn_args,
}
# ── Build tool_calls list in output_index order ─────────────
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(_pending_tools.keys()):
tc = _pending_tools[idx]
tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": tc["arguments"],
},
})
return response_text, tool_calls if tool_calls else None
async def _stream_responses_api_async(
self,
params: Dict[str, Any],
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
) -> tuple:
"""
Async streaming for Responses API.
Returns (response_text, tool_calls).
"""
import litellm
params["stream"] = True
response_text = ""
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
async for event in await litellm.aresponses(**params):
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
_pending_tools[idx]["arguments"] += delta_args
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(_pending_tools.keys()):
tc = _pending_tools[idx]
tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": tc["arguments"]},
})
return response_text, tool_calls if tool_calls else None
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
"""Prepare debug logging information for response methods"""
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
@@ -539,6 +539,229 @@ class OpenAIClient:
return result
# ─── Responses API helpers ──────────────────────────────────────────────
@staticmethod
def _supports_responses_api(model: str) -> bool:
"""
Returns True for OpenAI models that support the Responses API.
Mirrors LLM._supports_responses_api but works as a static method
on a plain model string.
"""
if not model:
return False
model_lower = model.lower()
# Exclude non-OpenAI providers
_non_openai = (
"ollama/", "ollama_chat/", "gemini/", "vertex_ai/",
"anthropic/", "claude", "deepseek/",
)
for prefix in _non_openai:
if model_lower.startswith(prefix):
return False
_openai_prefixes = (
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
"gpt-5",
"o1", "o3", "o4",
"chatgpt-4o",
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
"azure/gpt-5",
"azure/o1", "azure/o3", "azure/o4",
)
for prefix in _openai_prefixes:
if model_lower.startswith(prefix):
return True
# openai/ prefix
if model_lower.startswith("openai/"):
remainder = model_lower[len("openai/"):]
for prefix in _openai_prefixes:
if remainder.startswith(prefix):
return True
return False
def _use_responses_api(self, model: str) -> bool:
"""
Returns True if Responses API should be used for this call.
Only returns True when:
1. The model supports Responses API (OpenAI model)
2. No custom base_url is set (not a local server)
3. The openai SDK has the 'responses' attribute
"""
if self.base_url:
return False
if not self._supports_responses_api(model):
return False
try:
return hasattr(self.sync_client, 'responses')
except Exception:
return False
def _build_responses_input(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 1.0,
tools: Optional[List[Dict]] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Transform Chat Completions-style messages and tools into
Responses API parameters.
Mapping:
- system message → ``instructions``
- remaining msgs → ``input`` (list of role/content items)
- tools are flattened from Chat Completions nested format
"""
params: Dict[str, Any] = {"model": model}
# ── Extract system instructions ──────────────────────────────────
instructions = None
input_items: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "")
if role in ("system", "developer"):
content = msg.get("content", "")
if instructions is None:
instructions = content
else:
instructions += "\n" + content
else:
# Handle Chat Completions → Responses API format transforms
if role == "assistant" and msg.get("tool_calls"):
content = msg.get("content")
if content and content.strip():
input_items.append({"role": "assistant", "content": content})
for tc in msg["tool_calls"]:
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
# Skip items with empty name — API rejects them
if not fn_name:
continue
input_items.append({
"type": "function_call",
"call_id": tc_id,
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
})
elif role == "tool":
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": msg.get("content", ""),
})
else:
input_items.append(msg)
if instructions:
params["instructions"] = instructions
params["input"] = input_items
# ── Transform tools to Responses API format ──────────────────────
if tools:
responses_tools = []
for tool in tools:
if isinstance(tool, dict) and "function" in tool:
fn = tool["function"]
responses_tool = {
"type": "function",
"name": fn.get("name", ""),
}
if "description" in fn:
responses_tool["description"] = fn["description"]
if "parameters" in fn:
responses_tool["parameters"] = fn["parameters"]
if fn.get("strict") is not None:
responses_tool["strict"] = fn["strict"]
responses_tools.append(responses_tool)
else:
responses_tools.append(tool)
params["tools"] = responses_tools
params["tool_choice"] = kwargs.pop("tool_choice", "auto")
# ── Scalar params ────────────────────────────────────────────────
if temperature is not None:
params["temperature"] = temperature
return params
def _responses_to_chat_completion(self, response) -> ChatCompletion:
"""
Wrap a Responses API response into a ChatCompletion dataclass
so that all downstream code (tool loop, display, callbacks)
works without modification.
"""
response_text = ""
tool_calls_list: List[ToolCall] = []
output_items = getattr(response, 'output', None) or []
for item in output_items:
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
if item_type == "message":
content_list = getattr(item, "content", []) if not isinstance(item, dict) else item.get("content", [])
for content_block in content_list:
if isinstance(content_block, dict):
block_type = content_block.get("type", "")
block_text = content_block.get("text", "") or ""
else:
block_type = getattr(content_block, "type", "")
block_text = getattr(content_block, "text", "") or ""
if block_type == "output_text":
response_text += block_text
elif item_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls_list)}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "{}")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls_list)}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "{}")
tool_calls_list.append(ToolCall(
id=call_id,
type="function",
function={
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
}
))
# Build usage
raw_usage = getattr(response, 'usage', None)
usage = None
if raw_usage:
usage = CompletionUsage(
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
total_tokens=getattr(raw_usage, 'total_tokens', 0),
)
message = ChatCompletionMessage(
content=response_text if response_text else None,
role="assistant",
tool_calls=tool_calls_list if tool_calls_list else None,
)
choice = Choice(
finish_reason="tool_calls" if tool_calls_list else "stop",
index=0,
message=message,
)
return ChatCompletion(
id=getattr(response, 'id', 'resp_unknown'),
choices=[choice],
created=int(time.time()),
model=getattr(response, 'model', ''),
usage=usage,
)
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
"""Generate a tool definition from a callable function."""
try:
@@ -675,6 +898,44 @@ class OpenAIClient:
metadata={"model": model, "message_count": len(messages)}
))
# ── Responses API path ───────────────────────────────────────
if self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools, **kwargs
)
raw = self.sync_client.responses.create(**resp_params)
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
console.print(Markdown(response_text))
# Emit stream events for compatibility
if _emit:
now = time.perf_counter()
if response_text:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=now, content=response_text[:50]
))
stream_callback(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=now
))
stream_callback(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=now, metadata={"responses_api": True}
))
return final_response
except Exception as e:
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Create the response stream
response_stream = self.sync_client.chat.completions.create(
model=model,
@@ -897,6 +1158,49 @@ class OpenAIClient:
else:
stream_callback(event)
# ── Responses API path ───────────────────────────────────────
if self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools, **kwargs
)
raw = await self.async_client.responses.create(**resp_params)
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
console.print(Markdown(response_text))
# Emit stream events for compatibility
if _emit:
now = time.perf_counter()
async def _emit_event(evt):
if asyncio.iscoroutinefunction(stream_callback):
await stream_callback(evt)
else:
stream_callback(evt)
if response_text:
await _emit_event(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=now, content=response_text[:50]
))
await _emit_event(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=now
))
await _emit_event(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=now, metadata={"responses_api": True}
))
return final_response
except Exception as e:
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Create the response stream
response_stream = await self.async_client.chat.completions.create(
model=model,
@@ -1078,6 +1382,20 @@ class OpenAIClient:
Returns:
ChatCompletion object or stream iterator
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
tool_choice=tool_choice, **kwargs
)
raw = self.sync_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
# ── Chat Completions path ─────────────────────────────────────
params = {
"model": model,
"messages": messages,
@@ -1123,6 +1441,20 @@ class OpenAIClient:
Returns:
ChatCompletion object or async stream iterator
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
tool_choice=tool_choice, **kwargs
)
raw = await self.async_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
# ── Chat Completions path ─────────────────────────────────────
params = {
"model": model,
"messages": messages,
@@ -1308,6 +1640,19 @@ class OpenAIClient:
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
if response_content and response_content.strip() and tool_calls:
try:
execute_sync_callback(
'llm_content',
content=response_content.strip(),
agent_name=None,
)
except Exception:
pass # Narrative display is optional
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
@@ -1341,8 +1686,9 @@ class OpenAIClient:
# Always trigger callback for tool call tracking (even when verbose=False)
display_tool_call_fn = _get_display_tool_call()
# Execute the tool
tool_result = execute_tool_fn(function_name, arguments)
# Execute the tool (pass tool_call_id for event correlation)
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
# Trigger callback with structured parameters for status output
@@ -1492,6 +1838,20 @@ class OpenAIClient:
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
if response_content and response_content.strip() and tool_calls:
try:
from ..main import execute_sync_callback as _esc
_esc(
'llm_content',
content=response_content.strip(),
agent_name=None,
)
except Exception:
pass # Narrative display is optional
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
@@ -1529,15 +1889,16 @@ class OpenAIClient:
if verbose and console:
console.print(f"[dim]Arguments:[/dim] {arguments}")
# Execute the tool (async)
# Execute the tool (async) - pass tool_call_id for event correlation
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
if asyncio.iscoroutinefunction(execute_tool_fn):
tool_result = await execute_tool_fn(function_name, arguments)
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
else:
# Run sync function in executor
loop = asyncio.get_event_loop()
tool_result = await loop.run_in_executor(
None,
lambda: execute_tool_fn(function_name, arguments)
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
@@ -1718,9 +2079,10 @@ class OpenAIClient:
if verbose:
yield f"\n[Calling function: {function_name}]"
# Execute the tool with error handling
# Execute the tool with error handling (pass tool_call_id for event correlation)
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
try:
tool_result = execute_tool_fn(function_name, arguments)
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
except Exception as e:
results_str = f"Error executing function: {str(e)}"
+1 -1
View File
@@ -112,7 +112,7 @@ __all__ = [
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
# - retry: {attempt, max_attempts, error, retry_in_seconds}
SUPPORTED_CALLBACK_TYPES = [
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end',
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end', 'llm_content',
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
'retry',
]
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
from .store import FileScheduleStore
from .parser import parse_schedule
from .runner import ScheduleRunner
from .loop import ScheduleLoop
_module_cache = {}
@@ -55,6 +56,11 @@ def __getattr__(name: str):
_module_cache[name] = ScheduleRunner
return ScheduleRunner
if name == "ScheduleLoop":
from .loop import ScheduleLoop
_module_cache[name] = ScheduleLoop
return ScheduleLoop
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -65,4 +71,5 @@ __all__ = [
"FileScheduleStore",
"parse_schedule",
"ScheduleRunner",
"ScheduleLoop",
]
@@ -0,0 +1,126 @@
"""
Schedule Loop for PraisonAI Agents.
Polls ScheduleRunner for due jobs and fires a callback.
"""
import logging
import threading
import time
from typing import Callable, Optional
from .runner import ScheduleRunner
from .store import FileScheduleStore
logger = logging.getLogger(__name__)
class ScheduleLoop:
"""Tick loop that polls a ScheduleRunner and fires due jobs.
Runs on a **daemon thread** so it never blocks the caller and
is automatically cleaned up on process exit.
Example::
from praisonaiagents.scheduler import ScheduleLoop
def handle(job):
print(f"Firing: {job.name} — {job.message}")
loop = ScheduleLoop(on_trigger=handle, tick_seconds=30)
loop.start()
# … later …
loop.stop()
"""
def __init__(
self,
on_trigger: Callable,
store: Optional[FileScheduleStore] = None,
tick_seconds: float = 30.0,
):
"""
Args:
on_trigger: Called with each due ``ScheduleJob``.
store: Schedule store to poll. Defaults to the global
``FileScheduleStore()``.
tick_seconds: Seconds between polls (default 30).
"""
self._on_trigger = on_trigger
self._store = store or FileScheduleStore()
self._runner = ScheduleRunner(self._store)
self._tick = tick_seconds
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
# ── Public API ──────────────────────────────────────────────────────
@property
def is_running(self) -> bool:
"""Whether the loop thread is alive."""
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
"""Start the tick loop on a daemon thread.
Calling ``start()`` when already running is a no-op.
"""
if self.is_running:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, name="schedule-loop", daemon=True
)
self._thread.start()
logger.info("ScheduleLoop started (tick=%ss)", self._tick)
def stop(self, timeout: float = 5.0) -> None:
"""Signal the loop to stop and wait for the thread to exit.
Args:
timeout: Max seconds to wait for the thread to join.
"""
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
logger.info("ScheduleLoop stopped")
# ── Internal ────────────────────────────────────────────────────────
def _run(self) -> None:
"""Loop body executed on the daemon thread."""
while not self._stop_event.is_set():
try:
due = self._runner.get_due_jobs()
for job in due:
try:
self._on_trigger(job)
# Update last_run_at so the job won't re-fire
# until the next interval elapses.
job.last_run_at = time.time()
self._store.update(job)
self._emit_hook(job)
except Exception:
logger.exception(
"Error triggering schedule job %s (%s)",
job.name, job.id,
)
except Exception:
logger.exception("ScheduleLoop tick error")
# Sleep in small increments so stop() is responsive.
self._stop_event.wait(timeout=self._tick)
def _emit_hook(self, job) -> None:
"""Best-effort emit of SCHEDULE_TRIGGER notification.
Logs the trigger event. Hook-aware callers (e.g. bots) can
wire a proper ``HookRunner`` externally via the ``on_trigger``
callback.
"""
logger.debug(
"SCHEDULE_TRIGGER: job=%s id=%s", job.name, job.id
)
@@ -60,11 +60,14 @@ class DeliveryTarget:
``"slack"``, ``"whatsapp"``).
channel_id: Platform-specific chat / channel / group ID.
thread_id: Optional thread ID for threaded delivery.
session_id: Optional session ID to preserve conversation
context when the cron fires.
"""
channel: str = ""
channel_id: str = ""
thread_id: Optional[str] = None
session_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
@@ -73,6 +76,8 @@ class DeliveryTarget:
}
if self.thread_id is not None:
d["thread_id"] = self.thread_id
if self.session_id is not None:
d["session_id"] = self.session_id
return d
@classmethod
@@ -81,6 +86,7 @@ class DeliveryTarget:
channel=d.get("channel", ""),
channel_id=d.get("channel_id", ""),
thread_id=d.get("thread_id"),
session_id=d.get("session_id"),
)
@@ -25,7 +25,9 @@ class StreamEventType(Enum):
FIRST_TOKEN = "first_token" # First content delta received (TTFT marker)
DELTA_TEXT = "delta_text" # Text content delta
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
TOOL_CALL_END = "tool_call_end" # Tool call complete
TOOL_CALL_END = "tool_call_end" # Tool call complete (stream end marker)
TOOL_CALL_START = "tool_call_start" # Tool execution starting (complete parsed args)
TOOL_CALL_RESULT = "tool_call_result" # Tool execution completed (with result)
LAST_TOKEN = "last_token" # Final content delta
STREAM_END = "stream_end" # Stream completed successfully
ERROR = "error" # Error during streaming
@@ -138,9 +138,9 @@ TOOL_MAPPINGS = {
'get_available_providers': ('.web_search', None),
# Unified Web Crawl (auto-fallback across providers)
'web_crawl': ('.web_crawl', None),
'crawl_web': ('.web_crawl', None), # Alias
'get_available_crawl_providers': ('.web_crawl', None),
'web_crawl': ('.web_crawl_tools', None),
'crawl_web': ('.web_crawl_tools', None), # Alias
'get_available_crawl_providers': ('.web_crawl_tools', None),
# Skill Tools (for Agent Skills script execution)
'run_skill_script': ('.skill_tools', None),
@@ -163,6 +163,26 @@ TOOL_MAPPINGS = {
'is_ast_grep_available': ('.ast_grep_tool', None),
'get_ast_grep_tools': ('.ast_grep_tool', None),
'ast_grep_tools': ('.ast_grep_tool', None),
# Memory Tools (active memory store/search for agents)
'store_memory': ('.memory', None),
'search_memory': ('.memory', None),
# Learning Tools (active categorized knowledge store/search)
'store_learning': ('.learning', None),
'search_learning': ('.learning', None),
# Email Tools (AgentMail-based send/read/list/reply/create)
'send_email': ('.email_tools', None),
'list_emails': ('.email_tools', None),
'read_email': ('.email_tools', None),
'reply_email': ('.email_tools', None),
'list_inboxes': ('.email_tools', None),
'create_inbox': ('.email_tools', None),
# Email Tools (SMTP/IMAP-based — direct mailbox credentials)
'smtp_send_email': ('.email_tools', None),
'smtp_read_inbox': ('.email_tools', None),
'email_tools': ('.email_tools', None),
}
_instances = {} # Cache for class instances
@@ -221,10 +241,14 @@ def __getattr__(name: str) -> Any:
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
'schedule_add', 'schedule_list', 'schedule_remove',
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools'
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
'store_memory', 'search_memory',
'store_learning', 'search_learning',
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
'smtp_send_email', 'smtp_read_inbox'
]:
return getattr(module, name)
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,947 @@
"""
Email tools for PraisonAI Agents.
Generic tools (send_email, list_emails, etc.) auto-detect the backend:
- If AGENTMAIL_API_KEY is set → uses AgentMail API
- If EMAIL_ADDRESS + EMAIL_PASSWORD is set → uses IMAP/SMTP
- If both are set → prefers AgentMail
Backend-specific tools are also available:
- AgentMail: agentmail_send_email, agentmail_list_emails, etc.
- SMTP/IMAP: smtp_send_email, smtp_read_inbox, smtp_search_inbox, etc.
Example:
from praisonaiagents.tools.email_tools import send_email, list_emails, search_emails
agent = Agent(tools=[send_email, list_emails, search_emails])
# Works with either AgentMail or Gmail/Outlook — auto-detected from env vars
"""
from __future__ import annotations
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
# Lazy-loaded AgentMail client (module-level singleton)
_client = None
def _detect_backend() -> str:
"""Detect which email backend is available.
Returns 'agentmail' or 'smtp'. Prefers AgentMail if both configured.
"""
if os.environ.get("AGENTMAIL_API_KEY"):
return "agentmail"
if os.environ.get("EMAIL_ADDRESS") and os.environ.get("EMAIL_PASSWORD"):
return "smtp"
raise ValueError(
"No email backend configured. Set either:\n"
" - AGENTMAIL_API_KEY (+ AGENTMAIL_INBOX_ID) for AgentMail, or\n"
" - EMAIL_ADDRESS + EMAIL_PASSWORD for IMAP/SMTP (Gmail, Outlook, etc.)"
)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AgentMail Backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _get_client():
"""Lazy-load the AgentMail client from env vars."""
global _client
if _client is None:
api_key = os.environ.get("AGENTMAIL_API_KEY", "")
if not api_key:
raise ValueError(
"AGENTMAIL_API_KEY environment variable is required. "
"Get your key at https://agentmail.to"
)
try:
from agentmail import AgentMail
_client = AgentMail(api_key=api_key)
except ImportError:
raise ImportError(
"agentmail package not installed. "
"Install with: pip install agentmail"
)
return _client
def _get_inbox_id() -> str:
"""Get the default inbox ID from env."""
inbox_id = os.environ.get("AGENTMAIL_INBOX_ID", "")
if not inbox_id:
raise ValueError(
"AGENTMAIL_INBOX_ID environment variable is required. "
"Set it to your inbox email, e.g. [email protected]"
)
return inbox_id
def _normalize_message_id(message_id: str) -> str:
"""LLMs often strip angle brackets from message IDs — restore them."""
if message_id and "@" in message_id and not message_id.startswith("<"):
return f"<{message_id}>"
return message_id
def _agentmail_send_email(to: str, subject: str, body: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.messages.send(inbox_id, to=to, subject=subject, text=body)
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}"
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return f"Failed to send email: {e}"
def _agentmail_list_emails(limit: int = 10) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
response = client.inboxes.messages.list(inbox_id, limit=limit)
msg_list = response.messages if hasattr(response, "messages") else response
if not msg_list:
return "No emails found in inbox."
results = []
for msg in msg_list:
sender = getattr(msg, "from_", "") or "unknown"
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
preview = getattr(msg, "preview", "") or ""
msg_id = getattr(msg, "message_id", "")
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
f" ID: {msg_id}\n"
f" Date: {timestamp}"
)
count_info = getattr(response, "count", len(msg_list))
return f"Found {count_info} email(s) (showing up to {limit}):\n\n" + "\n\n".join(results)
except Exception as e:
logger.error(f"Failed to list emails: {e}")
return f"Failed to list emails: {e}"
def _agentmail_read_email(message_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
msg = client.inboxes.messages.get(inbox_id, message_id)
sender = getattr(msg, "from_", "") or "unknown"
to = getattr(msg, "to", []) or []
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
body = getattr(msg, "extracted_text", "") or getattr(msg, "text", "") or ""
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
in_reply_to = getattr(msg, "in_reply_to", "")
to_str = ", ".join(to) if isinstance(to, list) else str(to)
result = f"From: {sender}\nTo: {to_str}\nSubject: {subject}\nDate: {timestamp}\n"
if in_reply_to:
result += f"In-Reply-To: {in_reply_to}\n"
result += f"\n{body}"
return result
except Exception as e:
logger.error(f"Failed to read email {message_id}: {e}")
return f"Failed to read email: {e}"
def _agentmail_reply_email(message_id: str, body: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
result = client.inboxes.messages.reply(inbox_id, message_id, text=body)
reply_id = getattr(result, "message_id", "unknown")
logger.info(f"Reply sent: {reply_id}")
return f"Reply sent successfully (ID: {reply_id})"
except Exception as e:
logger.error(f"Failed to reply to {message_id}: {e}")
return f"Failed to reply: {e}"
def _agentmail_search_emails(
label: Optional[str] = None,
after_date: Optional[str] = None,
before_date: Optional[str] = None,
limit: int = 20,
) -> str:
from datetime import datetime as _dt
client = _get_client()
inbox_id = _get_inbox_id()
try:
kwargs = {"limit": min(limit, 50)}
if label:
kwargs["labels"] = [label]
if after_date:
kwargs["after"] = _dt.fromisoformat(after_date)
if before_date:
kwargs["before"] = _dt.fromisoformat(before_date)
response = client.inboxes.messages.list(inbox_id, **kwargs)
msg_list = response.messages if hasattr(response, "messages") else response
if not msg_list:
return "No emails matched the search criteria."
results = []
for msg in msg_list:
sender = getattr(msg, "from_", "") or "unknown"
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
preview = getattr(msg, "preview", "") or ""
msg_id = getattr(msg, "message_id", "")
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
labels = getattr(msg, "labels", []) or []
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
f" Labels: {', '.join(labels) if labels else 'none'}\n"
f" ID: {msg_id}\n"
f" Date: {timestamp}"
)
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except Exception as e:
logger.error(f"Failed to search emails: {e}")
return f"Failed to search emails: {e}"
def _agentmail_archive_email(message_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
client.inboxes.messages.update(
inbox_id, message_id,
add_labels=["ARCHIVED"], remove_labels=["INBOX"],
)
logger.info(f"Email archived: {message_id}")
return f"Email archived successfully (ID: {message_id})"
except Exception as e:
logger.error(f"Failed to archive email {message_id}: {e}")
return f"Failed to archive email: {e}"
def _agentmail_forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
kwargs = {"to": to}
if note:
kwargs["text"] = note
result = client.inboxes.messages.forward(inbox_id, message_id, **kwargs)
fwd_id = getattr(result, "message_id", "unknown")
logger.info(f"Email forwarded to {to}: {fwd_id}")
return f"Email forwarded to {to} (ID: {fwd_id})"
except Exception as e:
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:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.drafts.create(inbox_id, to=[to], subject=subject, text=body)
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."
except Exception as e:
logger.error(f"Failed to create draft: {e}")
return f"Failed to create draft: {e}"
def _agentmail_send_draft(draft_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.drafts.send(inbox_id, draft_id)
msg_id = getattr(result, "message_id", "unknown")
logger.info(f"Draft sent: {msg_id}")
return f"Draft sent successfully (Message ID: {msg_id})"
except Exception as e:
logger.error(f"Failed to send draft {draft_id}: {e}")
return f"Failed to send draft: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SMTP/IMAP Backend — uses standard mailbox credentials
# Zero external dependencies (Python stdlib smtplib + imaplib)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
_SMTP_DEFAULTS = {
"gmail.com": ("smtp.gmail.com", 587),
"googlemail.com": ("smtp.gmail.com", 587),
"outlook.com": ("smtp.office365.com", 587),
"hotmail.com": ("smtp.office365.com", 587),
"live.com": ("smtp.office365.com", 587),
"yahoo.com": ("smtp.mail.yahoo.com", 587),
"icloud.com": ("smtp.mail.me.com", 587),
}
_IMAP_DEFAULTS = {
"gmail.com": ("imap.gmail.com", 993),
"googlemail.com": ("imap.gmail.com", 993),
"outlook.com": ("outlook.office365.com", 993),
"hotmail.com": ("outlook.office365.com", 993),
"live.com": ("outlook.office365.com", 993),
"yahoo.com": ("imap.mail.yahoo.com", 993),
"icloud.com": ("imap.mail.me.com", 993),
}
def _get_smtp_config():
"""Get SMTP config from env vars with smart defaults."""
email_addr = os.environ.get("EMAIL_ADDRESS", "")
password = os.environ.get("EMAIL_PASSWORD", "")
if not email_addr or not password:
raise ValueError(
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required. "
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
)
domain = email_addr.split("@")[-1].lower()
default_smtp = _SMTP_DEFAULTS.get(domain, (f"smtp.{domain}", 587))
smtp_server = os.environ.get("EMAIL_SMTP_SERVER", default_smtp[0])
smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", str(default_smtp[1])))
return email_addr, password, smtp_server, smtp_port
def _get_imap_config():
"""Get IMAP config from env vars with smart defaults."""
email_addr = os.environ.get("EMAIL_ADDRESS", "")
password = os.environ.get("EMAIL_PASSWORD", "")
if not email_addr or not password:
raise ValueError(
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required."
)
domain = email_addr.split("@")[-1].lower()
default_imap = _IMAP_DEFAULTS.get(domain, (f"imap.{domain}", 993))
imap_server = os.environ.get("EMAIL_IMAP_SERVER", default_imap[0])
imap_port = int(os.environ.get("EMAIL_IMAP_PORT", str(default_imap[1])))
return email_addr, password, imap_server, imap_port
def _imap_connect(readonly: bool = True):
"""Connect to IMAP and select INBOX. Returns (mail, email_addr)."""
import imaplib
email_addr, password, imap_server, imap_port = _get_imap_config()
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
mail.login(email_addr, password)
mail.select("INBOX", readonly=readonly)
return mail, email_addr
def _decode_header_value(raw_value: str) -> str:
"""Decode a MIME-encoded header value."""
from email.header import decode_header
parts = decode_header(raw_value)
result = ""
for part, charset in parts:
if isinstance(part, bytes):
result += part.decode(charset or "utf-8", errors="replace")
else:
result += part
return result
def _extract_body_preview(msg, max_len: int = 100) -> str:
"""Extract plain text body preview from an email message."""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return payload.decode(
part.get_content_charset() or "utf-8", errors="replace"
)[:max_len]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(
msg.get_content_charset() or "utf-8", errors="replace"
)[:max_len]
return ""
def _smtp_send_email(to: str, subject: str, body: str) -> str:
import smtplib
from email.mime.text import MIMEText
try:
email_addr, password, smtp_server, smtp_port = _get_smtp_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
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}"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to send SMTP email to {to}: {e}")
return f"Failed to send email: {e}"
def _smtp_list_emails(limit: int = 10) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
status, data = mail.search(None, "ALL")
if status != "OK" or not data[0]:
mail.logout()
return "No emails found in inbox."
msg_ids = data[0].split()
latest_ids = msg_ids[-limit:]
latest_ids.reverse()
results = []
for uid in latest_ids:
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
continue
msg = email_lib.message_from_bytes(msg_data[0][1])
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
date = msg.get("Date", "")
preview = _extract_body_preview(msg)
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
f" Date: {date}"
)
mail.logout()
if not results:
return "No emails found in inbox."
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to read inbox: {e}")
return f"Failed to read inbox: {e}"
def _smtp_read_email(message_id: str) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
# Search by Message-ID header
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Email not found with Message-ID: {message_id}"
uid = data[0].split()[0]
status, msg_data = mail.fetch(uid, "(RFC822)")
mail.logout()
if status != "OK":
return f"Failed to fetch email: {message_id}"
msg = email_lib.message_from_bytes(msg_data[0][1])
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
to = msg.get("To", "")
date = msg.get("Date", "")
body = _extract_body_preview(msg, max_len=10000)
result = f"From: {sender}\nTo: {to}\nSubject: {subject}\nDate: {date}\n\n{body}"
return result
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to read email {message_id}: {e}")
return f"Failed to read email: {e}"
def _smtp_reply_email(message_id: str, body: str) -> str:
import smtplib
import email as email_lib
from email.mime.text import MIMEText
try:
# Read original email to get sender and subject
mail, email_addr = _imap_connect(readonly=True)
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Original email not found: {message_id}"
uid = data[0].split()[0]
status, msg_data = mail.fetch(uid, "(RFC822)")
mail.logout()
if status != "OK":
return f"Failed to fetch original email: {message_id}"
original = email_lib.message_from_bytes(msg_data[0][1])
reply_to = original.get("Reply-To") or original.get("From", "")
subject = original.get("Subject", "")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
# Send reply
_, password, smtp_server, smtp_port = _get_smtp_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = reply_to
msg["Subject"] = subject
msg["In-Reply-To"] = search_id
msg["References"] = search_id
msg["Auto-Submitted"] = "auto-replied"
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls()
server.login(email_addr, password)
server.send_message(msg)
logger.info(f"Reply sent to {reply_to}")
return f"Reply sent to {reply_to}"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to reply to {message_id}: {e}")
return f"Failed to reply: {e}"
def _smtp_search_emails(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
limit: int = 20,
) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
# Build IMAP search criteria
criteria = []
if from_addr:
criteria.append(f'FROM "{from_addr}"')
if subject:
criteria.append(f'SUBJECT "{subject}"')
if query:
criteria.append(f'TEXT "{query}"')
if not criteria:
criteria.append("ALL")
search_str = " ".join(criteria)
status, data = mail.search(None, search_str)
if status != "OK" or not data[0]:
mail.logout()
return "No emails matched the search criteria."
msg_ids = data[0].split()
latest_ids = msg_ids[-min(limit, 50):]
latest_ids.reverse()
results = []
for uid in latest_ids:
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
continue
msg = email_lib.message_from_bytes(msg_data[0][1])
msg_subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
date = msg.get("Date", "")
mid = msg.get("Message-ID", "")
preview = _extract_body_preview(msg)
results.append(
f"- From: {sender}\n"
f" Subject: {msg_subject}\n"
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
f" ID: {mid}\n"
f" Date: {date}"
)
mail.logout()
if not results:
return "No emails matched the search criteria."
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to search emails: {e}")
return f"Failed to search emails: {e}"
def _smtp_archive_email(message_id: str) -> str:
try:
import imaplib
mail, email_addr = _imap_connect(readonly=False)
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Email not found: {message_id}"
uid = data[0].split()[0]
# Gmail: copy to All Mail, then delete from INBOX
# Other: just mark as \Seen and \Deleted
domain = email_addr.split("@")[-1].lower()
if domain in ("gmail.com", "googlemail.com") or os.environ.get("EMAIL_IMAP_SERVER", "").endswith("gmail.com"):
mail.copy(uid, "[Gmail]/All Mail")
mail.store(uid, "+FLAGS", "\\Deleted")
else:
mail.store(uid, "+FLAGS", "(\\Seen \\Deleted)")
mail.expunge()
mail.logout()
logger.info(f"Email archived: {message_id}")
return f"Email archived successfully (ID: {message_id})"
except ValueError as e:
return str(e)
except Exception as e:
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:
import imaplib
import time
from email.mime.text import MIMEText
try:
email_addr, password, imap_server, imap_port = _get_imap_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
mail.login(email_addr, password)
# Gmail uses [Gmail]/Drafts, others use Drafts or DRAFTS
domain = email_addr.split("@")[-1].lower()
if domain in ("gmail.com", "googlemail.com") or imap_server.endswith("gmail.com"):
drafts_folder = "[Gmail]/Drafts"
else:
drafts_folder = "Drafts"
mail.append(drafts_folder, "\\Draft", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
mail.logout()
logger.info(f"Draft saved to {drafts_folder}")
return f"Draft saved to {drafts_folder} (To: {to}, Subject: {subject})"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to save draft: {e}")
return f"Failed to save draft: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Generic Tools — Auto-detect backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to someone.
Auto-detects backend: AgentMail (if AGENTMAIL_API_KEY set) or
SMTP (if EMAIL_ADDRESS + EMAIL_PASSWORD set).
Args:
to: Recipient email address (e.g. bob@example.com)
subject: Email subject line
body: Email body text content
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)
def list_emails(limit: int = 10) -> str:
"""List recent emails in the inbox.
Auto-detects backend: AgentMail or IMAP.
Args:
limit: Maximum number of emails to return (default 10)
Returns:
Summary of recent emails with sender, subject, and preview
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_list_emails(limit)
return _smtp_list_emails(limit)
def read_email(message_id: str) -> str:
"""Read the full content of a specific email.
Auto-detects backend: AgentMail or IMAP.
Args:
message_id: The message ID to read (get this from list_emails)
Returns:
Full email content including sender, subject, and body
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_read_email(message_id)
return _smtp_read_email(message_id)
def reply_email(message_id: str, body: str) -> str:
"""Reply to an email message.
Auto-detects backend: AgentMail or SMTP.
Args:
message_id: The message ID to reply to (get this from list_emails)
body: Reply body text
Returns:
Confirmation with the reply message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_reply_email(message_id, body)
return _smtp_reply_email(message_id, body)
def search_emails(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
label: Optional[str] = None,
after_date: Optional[str] = None,
before_date: Optional[str] = None,
limit: int = 20,
) -> str:
"""Search emails by keyword, sender, subject, label, or date range.
Auto-detects backend: AgentMail or IMAP.
AgentMail supports: label, after_date, before_date.
IMAP supports: query (text search), from_addr, subject.
Args:
query: Search text in email body (IMAP only)
from_addr: Filter by sender email (IMAP only)
subject: Filter by subject text (IMAP only)
label: Filter by label (AgentMail only, e.g. "INBOX", "SENT")
after_date: Only emails after this date (ISO format, e.g. "2025-01-15")
before_date: Only emails before this date (ISO format, e.g. "2025-03-01")
limit: Maximum number of results (default 20, max 50)
Returns:
Matching emails with sender, subject, preview, and ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_search_emails(label, after_date, before_date, limit)
return _smtp_search_emails(query, from_addr, subject, limit)
def archive_email(message_id: str) -> str:
"""Archive an email (remove from inbox, keep in archive).
Auto-detects backend: AgentMail or IMAP.
Gmail: moves to All Mail. Other providers: marks as deleted from inbox.
Args:
message_id: The message ID to archive (get this from list_emails or search_emails)
Returns:
Confirmation message
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_archive_email(message_id)
return _smtp_archive_email(message_id)
def forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
"""Forward an email to another recipient.
Requires AgentMail backend. IMAP/SMTP does not support forwarding
(workaround: read_email + send_email).
Args:
message_id: The message ID to forward (get this from list_emails)
to: Recipient email address to forward to
note: Optional note to include above the forwarded message
Returns:
Confirmation with the forwarded message ID
"""
backend = _detect_backend()
if backend == "agentmail":
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.
Auto-detects backend: AgentMail or IMAP.
AgentMail: use send_draft() to send later.
IMAP: saves to Drafts folder (send from your email client).
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
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)
def send_draft(draft_id: str) -> str:
"""Send a previously created email draft.
AgentMail only. IMAP drafts must be sent from your email client.
Args:
draft_id: The draft ID to send (get this from draft_email)
Returns:
Confirmation with the sent message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_send_draft(draft_id)
return "send_draft requires AgentMail. IMAP drafts must be sent from your email client."
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AgentMail-only tools (no IMAP equivalent)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def list_inboxes() -> str:
"""List all email inboxes available (AgentMail only).
Returns:
List of inbox email addresses
"""
client = _get_client()
try:
response = client.inboxes.list()
inbox_list = response.inboxes if hasattr(response, "inboxes") else response
if not inbox_list:
return "No inboxes found."
results = []
for inbox in inbox_list:
email = getattr(inbox, "inbox_id", "unknown")
display = getattr(inbox, "display_name", "") or ""
label = f"{email} ({display})" if display else email
results.append(f"- {label}")
return f"Found {len(results)} inbox(es):\n" + "\n".join(results)
except Exception as e:
logger.error(f"Failed to list inboxes: {e}")
return f"Failed to list inboxes: {e}"
def create_inbox(display_name: Optional[str] = None) -> str:
"""Create a new email inbox (AgentMail only).
Args:
display_name: Optional display name for the inbox
Returns:
Details of the created inbox
"""
client = _get_client()
try:
if display_name:
try:
from agentmail.inboxes.types import CreateInboxRequest
result = client.inboxes.create(
request=CreateInboxRequest(display_name=display_name)
)
except ImportError:
result = client.inboxes.create()
else:
result = client.inboxes.create()
inbox_id = getattr(result, "inbox_id", "unknown")
name = getattr(result, "display_name", "") or ""
logger.info(f"Inbox created: {inbox_id}")
label = f"{inbox_id} ({name})" if name else inbox_id
return f"Inbox created: {label}"
except Exception as e:
logger.error(f"Failed to create inbox: {e}")
return f"Failed to create inbox: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Backward-compatible aliases (smtp_ prefix)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def smtp_send_email(to: str, subject: str, body: str) -> str:
"""Send an email using SMTP (Gmail, Outlook, etc.).
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
Returns:
Confirmation message
"""
return _smtp_send_email(to, subject, body)
def smtp_read_inbox(limit: int = 10, folder: str = "INBOX") -> str:
"""Read recent emails from your mailbox using IMAP.
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
limit: Maximum number of emails to return (default 10)
folder: Mailbox folder to read from (default INBOX)
Returns:
Summary of recent emails with sender, subject, and preview
"""
return _smtp_list_emails(limit)
def smtp_search_inbox(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
limit: int = 20,
) -> str:
"""Search emails using IMAP SEARCH.
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
query: Search text in email body
from_addr: Filter by sender email address
subject: Filter by subject text
limit: Maximum number of results (default 20)
Returns:
Matching emails with sender, subject, preview
"""
return _smtp_search_emails(query, from_addr, subject, limit)
def smtp_archive_email(message_id: str) -> str:
"""Archive an email using IMAP.
Gmail: moves to All Mail. Other providers: marks as deleted.
Args:
message_id: The Message-ID header value
Returns:
Confirmation message
"""
return _smtp_archive_email(message_id)
def smtp_draft_email(to: str, subject: str, body: str) -> str:
"""Save an email draft using IMAP APPEND.
Saves to Drafts folder ([Gmail]/Drafts for Gmail).
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
Returns:
Confirmation message
"""
return _smtp_draft_email(to, subject, body)
@@ -60,6 +60,7 @@ class AgentState:
last_user_message: Optional[str] = None
last_agent_message: Optional[str] = None
memory: Optional[Any] = None
learn_manager: Optional[Any] = None
previous_tool_results: List[Any] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,143 @@
"""
Learning tools for PraisonAI agents.
Provides store_learning and search_learning as standard tool functions.
These are the Learn system counterparts to store_memory/search_memory.
Memory stores flat facts ("User's name is Alice").
Learning stores categorized knowledge ("User prefers bullet points" → persona).
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import store_learning, search_learning
agent = Agent(
instructions="You learn user preferences.",
memory=True,
learn=True,
tools=[store_learning, search_learning]
)
"""
from typing import Optional, List, Dict, Any
from .injected import Injected, AgentState, get_current_state
# Valid categories mapping to LearnManager capture methods
_CATEGORY_MAP = {
"persona": "capture_persona",
"insights": "capture_insight",
"patterns": "capture_pattern",
"decisions": "capture_decision",
"feedback": "capture_feedback",
"improvements": "capture_improvement",
}
def store_learning(
content: str,
category: str = "persona",
state: Injected[AgentState] = None,
) -> str:
"""Store a learning — a pattern, preference, insight, or decision.
Use this when you discover something worth remembering across sessions,
like user preferences, behavioral patterns, or important decisions.
Categories:
persona — User preferences and profile (default)
insights — Observations about the user or domain
patterns — Recurring behaviors or workflows
decisions — Decision records for consistency
Args:
content: The learning to store
category: Category — "persona", "insights", "patterns", or "decisions"
"""
# Resolve injected state
if state is None:
state = get_current_state()
learn_mgr = getattr(state, "learn_manager", None) if state else None
if not learn_mgr:
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
# Normalize category
cat = category.lower().strip()
if cat not in _CATEGORY_MAP:
return f"Unknown category '{category}'. Use: {', '.join(_CATEGORY_MAP.keys())}"
method_name = _CATEGORY_MAP[cat]
method = getattr(learn_mgr, method_name, None)
if not method:
return f"Learn manager does not support category '{cat}'."
try:
result = method(content)
# Friendly category labels for response
labels = {
"persona": "persona preference",
"insights": "insight",
"patterns": "pattern",
"decisions": "decision",
"feedback": "feedback",
"improvements": "improvement",
}
label = labels.get(cat, cat)
return f"Stored {label}: {content[:100]}"
except Exception as e:
return f"Error storing learning: {e}"
def search_learning(
query: str,
category: str = "",
limit: int = 5,
state: Injected[AgentState] = None,
) -> str:
"""Search learned knowledge — preferences, patterns, insights, decisions.
Use this to recall previously learned information about the user
or domain across sessions.
Args:
query: What to search for
category: Optional — filter to specific category (persona, insights, patterns, decisions)
limit: Maximum number of results to return
"""
# Resolve injected state
if state is None:
state = get_current_state()
learn_mgr = getattr(state, "learn_manager", None) if state else None
if not learn_mgr:
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
try:
all_results = learn_mgr.search(query, limit=limit)
except Exception as e:
return f"Error searching learnings: {e}"
# Filter by category if specified
if category:
cat = category.lower().strip()
all_results = {k: v for k, v in all_results.items() if k == cat}
if not all_results:
return f"No learnings found matching: {query}"
# Format results grouped by category
parts: List[str] = []
total = 0
for store_name, entries in all_results.items():
for entry in entries[:limit]:
text = entry.get("content", "") if isinstance(entry, dict) else str(entry)
if text:
parts.append(f"- [{store_name}] {text}")
total += 1
if not parts:
return f"No learnings found matching: {query}"
formatted = "\n".join(parts[:limit])
return f"Found {min(total, limit)} learnings:\n{formatted}"
@@ -0,0 +1,142 @@
"""
Memory tools for PraisonAI agents.
Provides store_memory and search_memory as standard tool functions.
Agents use them via tools=[store_memory, search_memory] — like any other tool.
Memory access is provided through the Injected[AgentState] mechanism,
which is hidden from the LLM's tool schema.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import store_memory, search_memory
agent = Agent(
instructions="You remember important facts.",
memory=True,
tools=[store_memory, search_memory]
)
"""
from typing import Optional, List, Dict, Any
from .injected import Injected, AgentState, get_current_state
def store_memory(
content: str,
memory_type: str = "long_term",
state: Injected[AgentState] = None,
) -> str:
"""Store important information for later recall.
Use this when the user tells you something worth remembering,
like preferences, facts, or important context.
Args:
content: The information to remember
memory_type: Type of memory — "short_term" for temporary, "long_term" for persistent
"""
# Resolve injected state
if state is None:
state = get_current_state()
memory = getattr(state, "memory", None) if state else None
if not memory:
return "Memory is not configured for this agent. Enable memory=True to use memory tools."
try:
if memory_type == "short_term":
if hasattr(memory, "store_short_term"):
memory.store_short_term(content)
elif hasattr(memory, "add_short_term"):
memory.add_short_term(content)
else:
return "Memory backend does not support short-term storage."
else:
# Default to long_term
if hasattr(memory, "store_long_term"):
memory.store_long_term(content)
elif hasattr(memory, "add_long_term"):
memory.add_long_term(content)
else:
return "Memory backend does not support long-term storage."
return f"Stored in {memory_type} memory: {content[:100]}"
except Exception as e:
return f"Error storing memory: {e}"
def search_memory(
query: str,
limit: int = 5,
state: Injected[AgentState] = None,
) -> str:
"""Search your memory for relevant information.
Use this when you need to recall something the user
previously told you or that you previously stored.
Args:
query: What to search for
limit: Maximum number of results to return
"""
# Resolve injected state
if state is None:
state = get_current_state()
memory = getattr(state, "memory", None) if state else None
if not memory:
return "Memory is not configured for this agent. Enable memory=True to use memory tools."
results: List[str] = []
try:
# Search long-term memory first (most important)
if hasattr(memory, "search_long_term"):
lt_results = memory.search_long_term(query, limit=limit)
if lt_results:
for item in lt_results:
text = _extract_text(item)
if text:
results.append(text)
# Also check short-term memory
if hasattr(memory, "search_short_term"):
st_results = memory.search_short_term(query, limit=limit)
if st_results:
for item in st_results:
text = _extract_text(item)
if text and text not in results:
results.append(text)
# Fallback: try generic search method
if not results and hasattr(memory, "search"):
generic_results = memory.search(query, limit=limit)
if generic_results:
for item in generic_results:
text = _extract_text(item)
if text:
results.append(text)
except Exception as e:
return f"Error searching memory: {e}"
if not results:
return f"No memories found matching: {query}"
# Format results
formatted = "\n".join(f"- {r}" for r in results[:limit])
return f"Found {len(results[:limit])} memories:\n{formatted}"
def _extract_text(item: Any) -> Optional[str]:
"""Extract text content from a memory search result."""
if isinstance(item, str):
return item
if isinstance(item, dict):
return item.get("text") or item.get("content") or item.get("memory") or str(item)
if hasattr(item, "content"):
return item.content
if hasattr(item, "text"):
return item.text
return str(item) if item else None
@@ -226,9 +226,9 @@ class MentionsParser:
"""Process @web:query mention."""
try:
# Try to use web search
from praisonaiagents.tools import duckduckgo_search
from praisonaiagents.tools import duckduckgo
results = duckduckgo_search(query, max_results=3)
results = duckduckgo(query, max_results=3)
if results:
return f"# Web Search: {query}\n{results}"
else:
@@ -236,7 +236,7 @@ class MentionsParser:
except ImportError:
self._log("Web search not available", logging.WARNING)
return f"# Web Search: {query}\n[Web search not available. Install with: pip install duckduckgo-search]"
return f"# Web Search: {query}\n[Web search not available. Install with: pip install ddgs]"
except Exception as e:
self._log(f"Error in web search: {e}", logging.ERROR)
return f"# Web Search: {query}\n[Error: {e}]"
@@ -78,13 +78,33 @@ BUILTIN_PROFILES: Dict[str, ToolProfile] = {
tools=["schedule_add", "schedule_list", "schedule_remove"],
description="Agent-centric scheduling",
),
"memory": ToolProfile(
name="memory",
tools=["store_memory", "search_memory"],
description="Active memory store/search (requires memory=True on Agent)",
),
"learning": ToolProfile(
name="learning",
tools=["store_learning", "search_learning"],
description="Categorized knowledge store/search (requires learn=True on Agent)",
),
"email": ToolProfile(
name="email",
tools=["send_email", "list_emails", "read_email", "reply_email"],
description="Email send/read via AgentMail API (requires AGENTMAIL_API_KEY)",
),
"smtp_email": ToolProfile(
name="smtp_email",
tools=["smtp_send_email", "smtp_read_inbox"],
description="Email send/read via SMTP/IMAP with mailbox credentials (requires EMAIL_ADDRESS, EMAIL_PASSWORD)",
),
}
def _build_autonomy_tools() -> List[str]:
"""Build the autonomy profile tools list (deduped)."""
tools = []
for profile_name in ["code_intelligence", "file_ops", "shell", "web"]:
for profile_name in ["code_intelligence", "file_ops", "shell", "web", "memory", "learning", "schedule"]:
profile = BUILTIN_PROFILES[profile_name]
for tool in profile.tools:
if tool not in tools:
@@ -96,7 +116,7 @@ def _build_autonomy_tools() -> List[str]:
AUTONOMY_PROFILE = ToolProfile(
name="autonomy",
tools=_build_autonomy_tools(),
description="Default tools for autonomous agents (file_ops + shell + web + code_intelligence)",
description="Default tools for autonomous agents (code_intelligence + file_ops + shell + web + memory + learning + schedule)",
)
@@ -43,6 +43,8 @@ def schedule_add(
message: str = "",
channel: str = "",
channel_id: str = "",
agent_id: str = "",
session_id: str = "",
) -> str:
"""Add a new scheduled job.
@@ -59,6 +61,10 @@ def schedule_add(
"whatsapp"). If set with channel_id, the result will
be sent back to that chat when the job fires.
channel_id: Target chat/channel/group ID on the platform.
agent_id: Which agent should execute this job. If empty, the
executor uses its default agent.
session_id: Optional session ID to preserve conversation context.
If set, the agent will have access to prior chat history.
Returns:
Confirmation string with the job id.
@@ -72,11 +78,17 @@ def schedule_add(
delivery = None
if channel and channel_id:
delivery = DeliveryTarget(
channel=channel, channel_id=channel_id,
channel=channel,
channel_id=channel_id,
session_id=session_id or None,
)
job = ScheduleJob(
name=name, schedule=sched, message=message, delivery=delivery,
name=name,
schedule=sched,
message=message,
agent_id=agent_id or None,
delivery=delivery,
)
store = _get_store()
@@ -88,7 +100,8 @@ def schedule_add(
store.add(job)
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
return f"Schedule '{name}' added (id: {job.id}, {schedule}{delivery_note})."
agent_note = f" agent={agent_id}" if agent_id else ""
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
except ValueError as e:
return f"Error adding schedule: {e}"
except Exception as e:
@@ -121,10 +134,15 @@ def schedule_list() -> str:
sched_str = f"at: {sched.at}"
else:
sched_str = str(sched.kind)
lines.append(
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
+ (f" — \"{j.message}\"" if j.message else "")
)
parts = [f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"]
if j.agent_id:
parts.append(f" agent={j.agent_id}")
if j.delivery:
parts.append(f" → {j.delivery.channel}:{j.delivery.channel_id}")
if j.message:
parts.append(f' — "{j.message}"')
lines.append("".join(parts))
return "\n".join(lines)
except Exception as e:
logger.error("schedule_list failed: %s", e, exc_info=True)
@@ -13,7 +13,8 @@ import logging
import os
import time
import platform
import psutil
# psutil is imported lazily inside methods that need it
# to avoid hard failure when the package is not installed
from typing import Dict, List, Optional, Union
from ..approval import require_approval
@@ -25,14 +26,8 @@ class ShellTools:
self._check_dependencies()
def _check_dependencies(self):
"""Check if required packages are installed."""
try:
import psutil
except ImportError:
raise ImportError(
"Required package not available. Please install: psutil\n"
"Run: pip install psutil"
)
"""Check if required packages are installed (lazy — no hard failure)."""
pass
@require_approval(risk_level="critical")
def execute_command(
@@ -117,11 +112,16 @@ class ShellTools:
except subprocess.TimeoutExpired:
# Kill process on timeout
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
parent.kill()
try:
import psutil
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
parent.kill()
except ImportError:
# Fallback: kill without psutil
process.kill()
return {
'stdout': '',
@@ -148,6 +148,10 @@ class ShellTools:
Returns:
List of process information dictionaries
"""
try:
import psutil
except ImportError:
return [{"error": "psutil is required for list_processes. Install with: pip install psutil"}]
try:
processes = []
for proc in psutil.process_iter(['pid', 'name', 'username', 'memory_percent', 'cpu_percent']):
@@ -187,6 +191,13 @@ class ShellTools:
Returns:
Dictionary with operation results
"""
try:
import psutil
except ImportError:
return {
'success': False,
'message': 'psutil is required for kill_process. Install with: pip install psutil'
}
try:
process = psutil.Process(pid)
if force:
@@ -222,6 +233,10 @@ class ShellTools:
Returns:
Dictionary with system information
"""
try:
import psutil
except ImportError:
return {"error": "psutil is required for get_system_info. Install with: pip install psutil"}
try:
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
@@ -256,12 +271,30 @@ class ShellTools:
logging.error(error_msg)
return {}
# Create instance for direct function access
_shell_tools = ShellTools()
execute_command = _shell_tools.execute_command
list_processes = _shell_tools.list_processes
kill_process = _shell_tools.kill_process
get_system_info = _shell_tools.get_system_info
# Lazy instance — deferred so module can be imported without psutil
_shell_tools = None
def _get_shell_tools():
global _shell_tools
if _shell_tools is None:
_shell_tools = ShellTools()
return _shell_tools
def execute_command(*args, **kwargs):
"""Execute a shell command safely."""
return _get_shell_tools().execute_command(*args, **kwargs)
def list_processes(*args, **kwargs):
"""List running processes with their details."""
return _get_shell_tools().list_processes(*args, **kwargs)
def kill_process(*args, **kwargs):
"""Kill a process by its PID."""
return _get_shell_tools().kill_process(*args, **kwargs)
def get_system_info(*args, **kwargs):
"""Get system information."""
return _get_shell_tools().get_system_info(*args, **kwargs)
if __name__ == "__main__":
# Example usage
@@ -4,6 +4,7 @@ A2A - Main A2A Interface Class
Exposes PraisonAI Agents via the A2A (Agent2Agent) protocol.
"""
import asyncio
import logging
from typing import List, Optional, TYPE_CHECKING
@@ -48,6 +49,9 @@ class A2A:
version: Version string for the agent
prefix: URL prefix for the router
tags: OpenAPI tags for the router
auth_token: Optional bearer token for authentication on POST /a2a.
When set, requests must include Authorization: Bearer <token>.
Discovery (/.well-known/agent.json) remains public per spec.
"""
def __init__(
@@ -60,6 +64,7 @@ class A2A:
version: str = "1.0.0",
prefix: str = "",
tags: Optional[List[str]] = None,
auth_token: Optional[str] = None,
):
if agent is None and agents is None:
raise ValueError("A2A requires an agent or agents instance")
@@ -70,6 +75,7 @@ class A2A:
self.version = version
self.prefix = prefix
self.tags = tags or ["A2A"]
self.auth_token = auth_token
# Set name from agent if not provided
if name:
@@ -112,6 +118,25 @@ class A2A:
)
return self._agent_card
def serve(self, host: str = "0.0.0.0", port: int = 8000):
"""
Start a standalone A2A server.
Convenience method that creates a FastAPI app, mounts the A2A
router, and runs it with uvicorn. For production use,
create your own FastAPI app and use get_router().
Args:
host: Host to bind to (default: 0.0.0.0)
port: Port to bind to (default: 8000)
"""
import uvicorn
from fastapi import FastAPI
app = FastAPI(title=self.name, version=self.version)
app.include_router(self.get_router())
uvicorn.run(app, host=host, port=port)
def get_router(self) -> "APIRouter":
"""
Get the FastAPI router for this A2A instance.
@@ -125,11 +150,22 @@ class A2A:
def _create_router(self) -> "APIRouter":
"""Create FastAPI router with A2A endpoints."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
router = APIRouter(prefix=self.prefix, tags=self.tags)
# Auth dependency — only applied to POST /a2a, not discovery endpoints
async def _verify_auth(authorization: Optional[str] = Header(None)):
"""Verify bearer token if auth_token is configured."""
if self.auth_token is None:
return # No auth configured — open access
if authorization is None:
raise HTTPException(status_code=401, detail="Authorization required")
parts = authorization.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer" or parts[1] != self.auth_token:
raise HTTPException(status_code=401, detail="Invalid token")
# Agent Card endpoint (well-known URI)
@router.get("/.well-known/agent.json")
async def get_agent_card():
@@ -147,8 +183,250 @@ class A2A:
"version": self.version,
}
# TODO: Add JSON-RPC endpoint for message/send
# TODO: Add streaming endpoint for message/stream
# TODO: Add task management endpoints
# JSON-RPC 2.0 endpoint for A2A protocol
# JSON-RPC endpoint — auth-protected when auth_token is set
from fastapi import Depends
_a2a_deps = [Depends(_verify_auth)] if self.auth_token else []
@router.post("/a2a", dependencies=_a2a_deps)
async def handle_jsonrpc(request: Request):
"""
Handle A2A JSON-RPC 2.0 requests.
Supported methods:
- message/send: Send a message and get a response
- message/stream: Send a message and stream the response
- tasks/get: Get task status and history
- tasks/cancel: Cancel a running task
"""
try:
body = await request.json()
except Exception:
return _jsonrpc_error(None, -32700, "Parse error: invalid JSON")
# Validate JSON-RPC format
if body.get("jsonrpc") != "2.0":
return _jsonrpc_error(
body.get("id"), -32600, "Invalid Request: jsonrpc must be '2.0'"
)
request_id = body.get("id")
method = body.get("method")
params = body.get("params", {})
# Route to handler
if method == "message/send":
return await self._handle_message_send(request_id, params)
elif method == "message/stream":
return await self._handle_message_stream(request_id, params)
elif method == "tasks/get":
return self._handle_tasks_get(request_id, params)
elif method == "tasks/cancel":
return self._handle_tasks_cancel(request_id, params)
else:
return _jsonrpc_error(
request_id, -32601, f"Method not found: {method}"
)
return router
async def _handle_message_send(self, request_id, params: dict):
"""Handle message/send — create task, run agent, return result."""
from fastapi.responses import JSONResponse
from praisonaiagents.ui.a2a.types import (
Message, TextPart, TaskState,
)
from praisonaiagents.ui.a2a.conversion import (
extract_user_input,
praisonai_to_a2a_message,
create_artifact,
)
# Validate params
msg_data = params.get("message")
if not msg_data:
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
# Parse the incoming message
try:
message = _parse_message(msg_data)
except Exception as e:
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
# Create task in store
task = self.task_store.create_task(message)
# Update to working
self.task_store.update_status(task.id, TaskState.WORKING)
try:
# Extract user input text
user_input = extract_user_input([message])
# Run agent or agents (offload sync call to thread pool)
if self.agent:
response = await asyncio.to_thread(self.agent.chat, user_input)
elif self.agents:
response = await asyncio.to_thread(self.agents.start, user_input)
else:
raise RuntimeError("No agent or agents configured")
# Create response message and artifact
response_msg = praisonai_to_a2a_message(
str(response), task_id=task.id
)
artifact = create_artifact(str(response))
# Update task store
self.task_store.add_to_history(task.id, response_msg)
self.task_store.add_artifact(task.id, artifact)
updated_task = self.task_store.update_status(task.id, TaskState.COMPLETED)
except Exception as e:
logger.error(f"Agent error in message/send: {e}")
updated_task = self.task_store.update_status(task.id, TaskState.FAILED)
# Return JSON-RPC response
return JSONResponse(content={
"jsonrpc": "2.0",
"id": request_id,
"result": updated_task.model_dump(by_alias=True, exclude_none=True),
})
async def _handle_message_stream(self, request_id, params: dict):
"""Handle message/stream — create task, stream agent response as SSE."""
from fastapi.responses import StreamingResponse
from praisonaiagents.ui.a2a.types import TaskState
from praisonaiagents.ui.a2a.streaming import stream_agent_response
from praisonaiagents.ui.a2a.conversion import extract_user_input
# Validate params
msg_data = params.get("message")
if not msg_data:
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
# Parse the incoming message
try:
message = _parse_message(msg_data)
except Exception as e:
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
# Create task in store
task = self.task_store.create_task(message)
# Extract user input
user_input = extract_user_input([message])
# Determine which entity to stream from
stream_entity = self.agent if self.agent else self.agents
# Stream the response using existing streaming helper
return StreamingResponse(
stream_agent_response(
agent=stream_entity,
user_input=user_input,
task_id=task.id,
context_id=task.context_id,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
def _handle_tasks_get(self, request_id, params: dict):
"""Handle tasks/get — return existing task."""
from fastapi.responses import JSONResponse
task_id = params.get("id")
if not task_id:
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
task = self.task_store.get_task(task_id)
if not task:
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
return JSONResponse(content={
"jsonrpc": "2.0",
"id": request_id,
"result": task.model_dump(by_alias=True, exclude_none=True),
})
def _handle_tasks_cancel(self, request_id, params: dict):
"""Handle tasks/cancel — cancel existing task."""
from fastapi.responses import JSONResponse
task_id = params.get("id")
if not task_id:
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
task = self.task_store.cancel_task(task_id)
if not task:
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
return JSONResponse(content={
"jsonrpc": "2.0",
"id": request_id,
"result": task.model_dump(by_alias=True, exclude_none=True),
})
# =============================================================================
# Helpers (module-level, no class dependency)
# =============================================================================
def _jsonrpc_error(request_id, code: int, message: str):
"""Create a JSON-RPC 2.0 error response."""
from fastapi.responses import JSONResponse
return JSONResponse(content={
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
})
def _parse_message(msg_data: dict):
"""Parse a raw dict into an A2A Message object.
Supports all A2A v1 Part types:
- text: TextPart
- url/fileWithUri: FilePart (file reference)
- data: DataPart (structured JSON)
"""
from praisonaiagents.ui.a2a.types import (
Message, TextPart, FilePart, DataPart, Role,
)
parts = []
for part_data in msg_data.get("parts", []):
if "text" in part_data:
parts.append(TextPart(
text=part_data["text"],
metadata=part_data.get("metadata"),
))
elif "url" in part_data or "fileWithUri" in part_data:
uri = part_data.get("url") or part_data.get("fileWithUri")
parts.append(FilePart(
file_uri=uri,
media_type=part_data.get("mediaType") or part_data.get("media_type"),
name=part_data.get("filename") or part_data.get("name"),
metadata=part_data.get("metadata"),
))
elif "data" in part_data:
parts.append(DataPart(
data=part_data["data"],
metadata=part_data.get("metadata"),
))
if not parts:
raise ValueError("Message must contain at least one part")
return Message(
message_id=msg_data.get("messageId", "msg-auto"),
role=msg_data.get("role", "user"),
parts=parts,
context_id=msg_data.get("contextId"),
task_id=msg_data.get("taskId"),
)
@@ -4,6 +4,7 @@ A2A Message Conversion
Convert between A2A protocol messages and PraisonAI internal format.
"""
import json
import uuid
from typing import Any, Dict, List, Optional
@@ -11,6 +12,8 @@ from praisonaiagents.ui.a2a.types import (
Message,
Role,
TextPart,
FilePart,
DataPart,
Artifact,
)
@@ -19,6 +22,14 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
"""
Convert A2A messages to PraisonAI/OpenAI chat format.
Handles all A2A Part types:
- TextPart → {"type": "text", "text": ...}
- FilePart → {"type": "image_url", "image_url": {"url": ...}}
- DataPart → {"type": "text", "text": json.dumps(data)}
Returns multimodal content list when non-text parts present,
plain string content when text-only.
Args:
messages: List of A2A Message objects
@@ -31,15 +42,33 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
# Map A2A role to OpenAI role
role = "user" if msg.role == Role.USER else "assistant"
# Combine all text parts
content_parts = []
has_non_text = False
for part in msg.parts:
if isinstance(part, TextPart):
content_parts.append(part.text)
content_parts.append({"type": "text", "text": part.text})
elif isinstance(part, FilePart) and part.file_uri:
has_non_text = True
content_parts.append({
"type": "image_url",
"image_url": {"url": part.file_uri},
})
elif isinstance(part, DataPart):
has_non_text = True
content_parts.append({
"type": "text",
"text": json.dumps(part.data),
})
elif hasattr(part, 'text'):
content_parts.append(part.text)
content_parts.append({"type": "text", "text": part.text})
content = " ".join(content_parts)
# Use multimodal list format when non-text parts present,
# plain string otherwise (backwards compat)
if has_non_text:
content = content_parts
else:
content = " ".join(p["text"] for p in content_parts)
result.append({
"role": role,
@@ -110,6 +139,9 @@ def extract_user_input(messages: List[Message]) -> str:
"""
Extract the last user input from a list of messages.
Handles all Part types: TextPart text is included directly,
FilePart URIs noted, DataPart serialized to JSON.
Args:
messages: List of A2A Message objects
@@ -119,13 +151,17 @@ def extract_user_input(messages: List[Message]) -> str:
# Find the last user message
for msg in reversed(messages):
if msg.role == Role.USER:
# Combine text parts
content_parts = []
for part in msg.parts:
if isinstance(part, TextPart):
content_parts.append(part.text)
elif isinstance(part, FilePart) and part.file_uri:
content_parts.append(f"[file: {part.file_uri}]")
elif isinstance(part, DataPart):
content_parts.append(json.dumps(part.data))
elif hasattr(part, 'text'):
content_parts.append(part.text)
return " ".join(content_parts)
return ""
+8 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.38"
version = "1.5.73"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -88,6 +88,11 @@ autonomy = [
"ast-grep-cli>=0.30.0"
]
# Web search (DuckDuckGo — zero-config, no API key required)
search = [
"ddgs>=9.0.0",
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -98,7 +103,8 @@ all = [
"praisonaiagents[api]",
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]"
"praisonaiagents[auth]",
"praisonaiagents[search]"
]
[tool.setuptools.packages.find]
+688 -2
View File
@@ -1,9 +1,15 @@
"""
Tests for Main A2A Class
Tests for A2A Class and JSON-RPC Endpoints
TDD: Write tests first, then implement A2A class.
TDD: Tests written first, then implementation.
"""
from unittest.mock import MagicMock, patch
# ============================================================================
# Existing Tests — A2A Class
# ============================================================================
class TestA2AClass:
"""Tests for A2A main class."""
@@ -133,3 +139,683 @@ class TestA2AWithTools:
assert card.skills is not None
assert len(card.skills) >= 1
# ============================================================================
# NEW: JSON-RPC Endpoint Tests
# ============================================================================
def _make_app():
"""Create a test FastAPI app with mocked agent."""
from fastapi import FastAPI
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
agent = Agent(name="JSON-RPC Test", role="Tester", goal="Test")
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
app = FastAPI()
app.include_router(a2a.get_router())
return app, a2a
class TestA2AJsonRpc:
"""Tests for A2A JSON-RPC endpoint (POST /a2a)."""
def test_post_a2a_invalid_jsonrpc(self):
"""Missing jsonrpc field → -32600 Invalid Request."""
from fastapi.testclient import TestClient
app, _ = _make_app()
client = TestClient(app)
response = client.post("/a2a", json={
"method": "message/send",
"id": "1",
"params": {}
})
assert response.status_code == 200
data = response.json()
assert "error" in data
assert data["error"]["code"] == -32600
def test_post_a2a_unknown_method(self):
"""Unknown method → -32601 Method not found."""
from fastapi.testclient import TestClient
app, _ = _make_app()
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "unknown/method",
"id": "1",
"params": {}
})
assert response.status_code == 200
data = response.json()
assert "error" in data
assert data["error"]["code"] == -32601
def test_post_a2a_message_send(self):
"""Valid message/send → Task result with completed status."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
# Mock agent.chat to avoid LLM call
a2a.agent.chat = MagicMock(return_value="Hello from agent!")
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "req-1",
"params": {
"message": {
"messageId": "msg-1",
"role": "user",
"parts": [{"text": "Hello"}]
}
}
})
assert response.status_code == 200
data = response.json()
assert data["jsonrpc"] == "2.0"
assert data["id"] == "req-1"
assert "result" in data
result = data["result"]
assert "id" in result
assert result["status"]["state"] == "completed"
assert result["artifacts"] is not None
assert len(result["artifacts"]) > 0
def test_post_a2a_message_send_creates_task_in_store(self):
"""message/send should create a task in TaskStore."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
a2a.agent.chat = MagicMock(return_value="OK")
client = TestClient(app)
# Store should be empty initially
assert len(a2a.task_store.list_tasks()) == 0
client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hi"}]
}
}
})
# Store should have one task
assert len(a2a.task_store.list_tasks()) == 1
def test_post_a2a_message_send_agent_error(self):
"""Agent throws → -32603 Internal error."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
a2a.agent.chat = MagicMock(side_effect=RuntimeError("LLM failed"))
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "err-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hi"}]
}
}
})
assert response.status_code == 200
data = response.json()
# Should return a task in failed state, not a JSON-RPC error
assert "result" in data
assert data["result"]["status"]["state"] == "failed"
def test_post_a2a_tasks_get(self):
"""tasks/get → returns existing task."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
a2a.agent.chat = MagicMock(return_value="Done")
client = TestClient(app)
# First, create a task via message/send
send_resp = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hi"}]
}
}
})
task_id = send_resp.json()["result"]["id"]
# Now get the task
get_resp = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "tasks/get",
"id": "2",
"params": {"id": task_id}
})
assert get_resp.status_code == 200
data = get_resp.json()
assert "result" in data
assert data["result"]["id"] == task_id
def test_post_a2a_tasks_get_not_found(self):
"""tasks/get with nonexistent id → -32000 Task not found."""
from fastapi.testclient import TestClient
app, _ = _make_app()
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "tasks/get",
"id": "1",
"params": {"id": "nonexistent"}
})
assert response.status_code == 200
data = response.json()
assert "error" in data
assert data["error"]["code"] == -32000
def test_post_a2a_tasks_cancel(self):
"""tasks/cancel → cancels existing task."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
a2a.agent.chat = MagicMock(return_value="Done")
client = TestClient(app)
# Create a task
send_resp = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hi"}]
}
}
})
task_id = send_resp.json()["result"]["id"]
# Cancel it
cancel_resp = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "tasks/cancel",
"id": "2",
"params": {"id": task_id}
})
assert cancel_resp.status_code == 200
data = cancel_resp.json()
assert "result" in data
assert data["result"]["status"]["state"] == "cancelled"
def test_post_a2a_message_stream(self):
"""message/stream → returns SSE streaming response."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
a2a.agent.chat = MagicMock(return_value="Streamed response")
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/stream",
"id": "stream-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hello stream"}]
}
}
})
assert response.status_code == 200
# SSE response should contain event data
text = response.text
assert "event:" in text
assert "data:" in text
def test_post_a2a_missing_params(self):
"""message/send without params.message → -32602 Invalid params."""
from fastapi.testclient import TestClient
app, _ = _make_app()
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "1",
"params": {}
})
assert response.status_code == 200
data = response.json()
assert "error" in data
assert data["error"]["code"] == -32602
# ============================================================================
# G11: AgentTeam Routing Tests
# ============================================================================
def _make_team_app():
"""Create test app with mocked AgentTeam (agents=)."""
from fastapi import FastAPI
from praisonaiagents.ui.a2a import A2A
# Mock an AgentTeam with .start() method
mock_team = MagicMock()
mock_team.name = "Test Team"
mock_team.start = MagicMock(return_value="Team result")
a2a = A2A(agents=mock_team, name="Team A2A")
app = FastAPI()
app.include_router(a2a.get_router())
return app, a2a, mock_team
class TestA2AAgentTeamRouting:
"""G11: Tests for AgentTeam routing via agents= param."""
def test_a2a_agents_message_send(self):
"""message/send with agents= should call team.start()."""
from fastapi.testclient import TestClient
app, a2a, mock_team = _make_team_app()
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "team-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Research AI"}]
}
}
})
assert response.status_code == 200
data = response.json()
assert "result" in data
assert data["result"]["status"]["state"] == "completed"
# Verify team.start() was called, not agent.chat()
mock_team.start.assert_called_once()
def test_a2a_agents_message_stream(self):
"""message/stream with agents= should work."""
from fastapi.testclient import TestClient
app, a2a, mock_team = _make_team_app()
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/stream",
"id": "team-s1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Stream this"}]
}
}
})
assert response.status_code == 200
text = response.text
assert "event:" in text
def test_a2a_agent_takes_priority(self):
"""When both agent= and agents= provided, agent takes priority."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
agent = Agent(name="Priority", role="Tester", goal="Test")
mock_team = MagicMock()
mock_team.name = "Team"
a2a = A2A(agent=agent, agents=mock_team)
a2a.agent.chat = MagicMock(return_value="Agent wins")
app = FastAPI()
app.include_router(a2a.get_router())
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "pri-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Who responds?"}]
}
}
})
assert response.status_code == 200
data = response.json()
assert "result" in data
# agent.chat should be called, not team.start
a2a.agent.chat.assert_called_once()
mock_team.start.assert_not_called()
# ============================================================================
# G9: serve() Convenience Tests
# ============================================================================
class TestA2AServe:
"""G9: Tests for A2A.serve() convenience method."""
def test_a2a_serve_method_exists(self):
"""A2A should have a serve() method."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
agent = Agent(name="Serve", role="Helper", goal="Help")
a2a = A2A(agent=agent)
assert hasattr(a2a, 'serve')
assert callable(a2a.serve)
def test_a2a_serve_creates_app(self):
"""serve() should create a FastAPI app and call uvicorn.run."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
agent = Agent(name="Serve", role="Helper", goal="Help")
a2a = A2A(agent=agent)
with patch("uvicorn.run") as mock_run:
a2a.serve(host="127.0.0.1", port=9999)
mock_run.assert_called_once()
# Verify the app passed to uvicorn is a FastAPI instance
call_args = mock_run.call_args
assert call_args[1].get("host") == "127.0.0.1"
assert call_args[1].get("port") == 9999
# ============================================================================
# G8: Auth Token Tests
# ============================================================================
def _make_auth_app(auth_token=None):
"""Create test app with optional auth_token."""
from fastapi import FastAPI
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
agent = Agent(name="Auth Test", role="Tester", goal="Test")
a2a = A2A(agent=agent, auth_token=auth_token)
app = FastAPI()
app.include_router(a2a.get_router())
return app, a2a
class TestA2AAuth:
"""G8: Tests for auth_token support."""
def test_a2a_auth_valid_token(self):
"""Valid bearer token → 200."""
from fastapi.testclient import TestClient
app, a2a = _make_auth_app(auth_token="sk-secret")
a2a.agent.chat = MagicMock(return_value="Authed")
client = TestClient(app)
response = client.post(
"/a2a",
json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "auth-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hello"}]
}
}
},
headers={"Authorization": "Bearer sk-secret"}
)
assert response.status_code == 200
data = response.json()
assert "result" in data
def test_a2a_auth_invalid_token(self):
"""Wrong bearer token → 401."""
from fastapi.testclient import TestClient
app, _ = _make_auth_app(auth_token="sk-secret")
client = TestClient(app)
response = client.post(
"/a2a",
json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "auth-2",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hello"}]
}
}
},
headers={"Authorization": "Bearer wrong-token"}
)
assert response.status_code == 401
def test_a2a_auth_missing_header(self):
"""No Authorization header → 401."""
from fastapi.testclient import TestClient
app, _ = _make_auth_app(auth_token="sk-secret")
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "auth-3",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hello"}]
}
}
})
assert response.status_code == 401
def test_a2a_no_auth_configured(self):
"""No auth_token set → open access (200)."""
from fastapi.testclient import TestClient
app, a2a = _make_auth_app(auth_token=None)
a2a.agent.chat = MagicMock(return_value="Open")
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "no-auth-1",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{"text": "Hello"}]
}
}
})
assert response.status_code == 200
data = response.json()
assert "result" in data
def test_a2a_auth_on_discovery_endpoint(self):
"""Agent card endpoint should remain open even with auth."""
from fastapi.testclient import TestClient
app, _ = _make_auth_app(auth_token="sk-secret")
client = TestClient(app)
# Discovery endpoints should be public per A2A spec
response = client.get("/.well-known/agent.json")
assert response.status_code == 200
# ============================================================================
# G13: Part Conversion Tests
# ============================================================================
class TestA2APartConversion:
"""G13: Tests for url/data part handling in _parse_message and conversion."""
def test_parse_message_with_url_part(self):
"""Parts with 'url' field should be parsed."""
from praisonaiagents.ui.a2a.a2a import _parse_message
msg = _parse_message({
"messageId": "m1",
"role": "user",
"parts": [
{"url": "https://example.com/img.png", "mediaType": "image/png"}
]
})
assert len(msg.parts) == 1
def test_parse_message_with_data_part(self):
"""Parts with 'data' field should be parsed."""
from praisonaiagents.ui.a2a.a2a import _parse_message
msg = _parse_message({
"messageId": "m1",
"role": "user",
"parts": [
{"data": {"key": "value"}}
]
})
assert len(msg.parts) == 1
def test_parse_message_mixed_parts(self):
"""Mixed text + url + data parts should all be parsed."""
from praisonaiagents.ui.a2a.a2a import _parse_message
msg = _parse_message({
"messageId": "m1",
"role": "user",
"parts": [
{"text": "Hello"},
{"url": "https://example.com/doc.pdf", "mediaType": "application/pdf"},
{"data": {"x": 1}}
]
})
assert len(msg.parts) == 3
def test_conversion_url_part_to_openai(self):
"""FilePart with url should convert to OpenAI image_url format."""
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
from praisonaiagents.ui.a2a.types import Message, FilePart, Role
msg = Message(
message_id="m1",
role=Role.USER,
parts=[FilePart(file_uri="https://example.com/img.png", media_type="image/png")],
)
result = a2a_to_praisonai_messages([msg])
assert len(result) == 1
content = result[0]["content"]
# Should contain image_url reference
assert isinstance(content, list)
assert any(p.get("type") == "image_url" for p in content)
def test_conversion_data_part_to_openai(self):
"""DataPart should convert to text with JSON content."""
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
from praisonaiagents.ui.a2a.types import Message, DataPart, Role
msg = Message(
message_id="m1",
role=Role.USER,
parts=[DataPart(data={"key": "value"})],
)
result = a2a_to_praisonai_messages([msg])
assert len(result) == 1
content = result[0]["content"]
assert isinstance(content, list)
assert any("key" in str(p) for p in content)
def test_extract_user_input_with_url_parts(self):
"""extract_user_input should handle non-text parts gracefully."""
from praisonaiagents.ui.a2a.conversion import extract_user_input
from praisonaiagents.ui.a2a.types import Message, FilePart, TextPart, Role
msg = Message(
message_id="m1",
role=Role.USER,
parts=[
TextPart(text="Please analyze"),
FilePart(file_uri="https://example.com/img.png"),
],
)
result = extract_user_input([msg])
assert "analyze" in result
@@ -0,0 +1,2 @@
# Auto-generated profile results
*.jsonl
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Complex 10-step task comparison: wrapper vs caller vs iterative.
Tests the tool_completion fix with a realistic multi-tool prompt that requires:
1. Web search (3x for frameworks)
2. File creation (Python dict)
3. Code execution (verify dict)
4. File write (markdown report)
5. File read (verify report)
6. List files (confirm both exist)
7. System info
8. Code analysis
9. Code formatting
10. Web search (Python 3.13)
"""
import sys
import time
import os
# Ensure local package is used
sys.path.insert(0, os.path.dirname(__file__))
from praisonaiagents import Agent
COMPLEX_PROMPT = """Research the top 3 Python web frameworks (Django, FastAPI, Flask), then:
1) Search the web for each framework's latest version and key features
2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data
3) Execute the code to verify the dictionary is valid
4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format
5) Read back the report file to verify it was written correctly
6) List the files in /tmp to confirm both files exist
7) Get system info to note what OS this report was generated on
8) Analyze the Python code you wrote for any issues
9) Format the Python code properly
10) Finally search for any recent news about Python 3.13 features"""
def run_test(name, agent, prompt):
"""Run agent and return results dict."""
print(f"\n{'='*70}")
print(f"TEST: {name}")
print(f"{'='*70}\n")
start = time.time()
try:
result = agent.start(prompt)
except Exception as e:
return {
"name": name,
"duration": time.time() - start,
"error": str(e),
"success": False,
}
duration = time.time() - start
output = result.output if hasattr(result, "output") else str(result)
reason = getattr(result, "completion_reason", None)
iters = getattr(result, "iterations", None)
success = getattr(result, "success", True)
print(f"\n--- {name} RESULTS ---")
print(f"Duration: {duration:.1f}s")
if reason:
print(f"Completion reason: {reason}")
if iters:
print(f"Iterations: {iters}")
print(f"Response length: {len(output or '')} chars")
print(f"Success: {success}")
if output:
print(f"\nFirst 600 chars of response:")
print(output[:600])
return {
"name": name,
"duration": duration,
"reason": reason,
"iterations": iters,
"length": len(output or ""),
"success": success,
}
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "all"
results = []
if mode in ("wrapper", "all"):
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
agent = Agent(
name="wrapper_agent",
instructions="You are a helpful research assistant.",
llm="gpt-4o-mini",
)
results.append(run_test("wrapper", agent, COMPLEX_PROMPT))
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
if mode in ("caller", "all"):
agent = Agent(
name="caller_agent",
instructions="You are a helpful research assistant.",
llm="gpt-4o-mini",
autonomy={
"level": "full_auto",
"mode": "caller", # Single chat(), no loop
},
)
results.append(run_test("caller", agent, COMPLEX_PROMPT))
if mode in ("iterative", "all"):
agent = Agent(
name="iterative_agent",
instructions="You are a helpful research assistant.",
llm="gpt-4o-mini",
autonomy={
"level": "full_auto",
"mode": "iterative",
"max_iterations": 5,
},
)
results.append(run_test("iterative", agent, COMPLEX_PROMPT))
# Summary
print(f"\n{'='*70}")
print("COMPARISON SUMMARY")
print(f"{'='*70}")
for r in results:
parts = [f"{r['name']:15s}: {r['duration']:6.1f}s"]
if r.get("reason"):
parts.append(f"reason={r['reason']}")
if r.get("iterations"):
parts.append(f"iters={r['iterations']}")
parts.append(f"len={r['length']}")
parts.append(f"ok={r['success']}")
print(" " + " | ".join(parts))
# Check if /tmp files were created
print(f"\n--- File Creation Check ---")
for f in ["/tmp/framework_comparison.py", "/tmp/framework_report.md"]:
exists = os.path.exists(f)
size = os.path.getsize(f) if exists else 0
print(f" {f}: {'EXISTS' if exists else 'MISSING'} ({size} bytes)")
if __name__ == "__main__":
main()
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Root-Cause Diagnosis: WHY does iterative mode loop / repeat?
Uses only LOW-RISK tools (internet_search, get_system_info, list_files)
to avoid approval gates and isolate the looping behavior.
"""
import time
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from praisonaiagents import Agent
from praisonaiagents.tools import list_files, get_system_info, internet_search
# Simpler task using only low-risk tools
TASK = (
"Do these 3 things: "
"1) Search the web for 'Python 3.13 new features', "
"2) Get system info to identify the current OS, "
"3) List files in /tmp. "
"After completing all 3 tasks, summarize the results."
)
TOOLS = [list_files, get_system_info, internet_search]
def run_test(name, agent, task):
"""Run a test and print results."""
print(f"\n{'='*70}")
print(f"TEST: {name}")
print(f"{'='*70}")
start = time.time()
result = agent.start(task)
elapsed = time.time() - start
if hasattr(result, 'output'):
# AutonomyResult
print(f"\n--- {name} RESULTS ---")
print(f"Duration: {elapsed:.1f}s")
print(f"Completion reason: {result.completion_reason}")
print(f"Iterations: {result.iterations}")
print(f"Response length: {len(result.output or '')} chars")
print(f"Success: {result.success}")
out = result.output or ""
else:
out = str(result) if result else ""
print(f"\n--- {name} RESULTS ---")
print(f"Duration: {elapsed:.1f}s")
print(f"Response length: {len(out)} chars")
# Show first 600 chars
print(f"\nFirst 600 chars of response:")
print(out[:600])
return elapsed, result
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("test", choices=["wrapper", "iterative", "caller", "all"], default="all", nargs="?")
args = parser.parse_args()
results = {}
if args.test in ("wrapper", "all"):
agent = Agent(
name="WrapperTest",
instructions="You are a helpful AI assistant. Complete the task and stop.",
tools=TOOLS,
)
results["wrapper"] = run_test("WRAPPER (no autonomy)", agent, TASK)
if args.test in ("caller", "all"):
agent = Agent(
name="CallerTest",
instructions="You are a helpful AI assistant. Complete the task and stop.",
tools=TOOLS,
autonomy={"mode": "caller"},
)
results["caller"] = run_test("CALLER (autonomy=caller)", agent, TASK)
if args.test in ("iterative", "all"):
agent = Agent(
name="IterativeTest",
instructions="You are a helpful AI assistant. Complete the task and stop.",
tools=TOOLS,
autonomy={
"mode": "iterative",
"max_iterations": 5,
"clear_context": False,
},
)
results["iterative"] = run_test("ITERATIVE (autonomy=iterative, max=5)", agent, TASK)
# Summary
if len(results) > 1:
print(f"\n{'='*70}")
print("COMPARISON SUMMARY")
print(f"{'='*70}")
for name, (elapsed, result) in results.items():
if hasattr(result, 'output'):
print(f" {name:15s}: {elapsed:6.1f}s | reason={result.completion_reason} | iters={result.iterations} | len={len(result.output or '')}")
else:
print(f" {name:15s}: {elapsed:6.1f}s | len={len(str(result))}")
if __name__ == "__main__":
main()
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""
Deep performance profiling integration test.
Runs cProfile-wrapped Agent prompts and stores full function-level
breakdown as JSONL for regression tracking. Each run appends a
timestamped record so you can compare performance over time.
Usage:
# Run with pytest (records stored automatically)
pytest tests/integration/test_performance_profile.py -v -s
# Run directly (more detailed output)
python tests/integration/test_performance_profile.py
# Compare recent results
python tests/integration/test_performance_profile.py --compare
# Profile a specific model
python tests/integration/test_performance_profile.py --model gpt-5-nano
# Compare two models
PROFILE_MODELS=gpt-5-nano,gpt-4o-mini python tests/integration/test_performance_profile.py
Results are stored in:
tests/integration/profile_results/performance_history.jsonl
"""
import cProfile
import json
import os
import platform
import pstats
import sys
import time
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
from statistics import mean, median, stdev
from typing import Any, Dict, List, Optional, Tuple
import pytest
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
RESULTS_DIR = Path(__file__).parent / "profile_results"
HISTORY_FILE = RESULTS_DIR / "performance_history.jsonl"
# Default model — override via PROFILE_MODEL env var
DEFAULT_MODEL = os.environ.get("PROFILE_MODEL", "gpt-4o-mini")
# Number of top functions to record
TOP_FUNCTIONS = 30
# Prompts
SIMPLE_PROMPT = "What is 2+2? Reply with just the number."
COMPLEX_PROMPT = (
"List the top 3 programming languages by popularity in 2024. "
"For each, give the name and one key strength. Keep it under 100 words."
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_results_dir():
"""Create results directory if it doesn't exist."""
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
gitignore = RESULTS_DIR / ".gitignore"
if not gitignore.exists():
gitignore.write_text("# Auto-generated profile results\n*.jsonl\n*.txt\n")
def _get_version() -> str:
"""Get the current praisonaiagents version."""
try:
from praisonaiagents import __version__
return __version__
except (ImportError, AttributeError):
return "unknown"
def _extract_function_stats(profiler: cProfile.Profile, top_n: int = TOP_FUNCTIONS) -> List[Dict]:
"""Extract top function stats from a cProfile.Profile instance."""
stats = pstats.Stats(profiler, stream=StringIO())
stats.sort_stats("cumulative")
function_stats = []
for (filename, line, name), (cc, nc, tt, ct, callers) in stats.stats.items():
function_stats.append({
"name": name,
"file": os.path.basename(filename) if filename else "",
"line": line,
"calls": nc,
"total_ms": round(tt * 1000, 2),
"cumulative_ms": round(ct * 1000, 2),
})
function_stats.sort(key=lambda x: x["cumulative_ms"], reverse=True)
return function_stats[:top_n]
def _extract_category_breakdown(function_stats: List[Dict]) -> Dict[str, float]:
"""Categorize function time into buckets: network, imports, framework, other."""
categories = {
"network_ms": 0.0,
"imports_ms": 0.0,
"framework_ms": 0.0,
"other_ms": 0.0,
}
seen = set()
for fn in function_stats:
key = (fn["name"], fn["file"])
if key in seen:
continue
seen.add(key)
name = fn["name"]
file = fn["file"]
cum = fn["cumulative_ms"]
# Network: SSL, socket, recv, send
if name in ("recv", "send", "connect") or "ssl" in file.lower() or "_socket" in file:
categories["network_ms"] = max(categories["network_ms"], cum)
# Imports
elif name in ("_find_and_load", "_find_and_load_unlocked", "_load_unlocked",
"exec_module", "_call_with_frames_removed") or "importlib" in file:
categories["imports_ms"] = max(categories["imports_ms"], cum)
# Framework
elif "praisonai" in file.lower() or name in ("chat", "get_response", "start",
"_chat_impl", "create_completion"):
categories["framework_ms"] = max(categories["framework_ms"], cum)
return categories
def _run_deep_profile(
prompt: str,
model: str = DEFAULT_MODEL,
runs: int = 3,
) -> Dict[str, Any]:
"""
Run a cProfile-wrapped Agent execution (deep profile).
Captures:
- Wall-clock timing for agent init and execution phases
- cProfile function-level stats (top N by cumulative time)
- Category breakdown (network, imports, framework)
"""
from praisonaiagents import Agent
all_runs = []
for i in range(runs):
# --- Phase 1: Agent init (timed but not cProfiled) ---
init_start = time.perf_counter()
agent = Agent(
instructions="Be concise.",
llm={"model": model},
output="silent",
)
init_ms = (time.perf_counter() - init_start) * 1000
# --- Phase 2: Execution with cProfile deep profiling ---
profiler = cProfile.Profile()
exec_start = time.perf_counter()
profiler.enable()
try:
result = agent.start(prompt)
finally:
profiler.disable()
exec_ms = (time.perf_counter() - exec_start) * 1000
total_ms = init_ms + exec_ms
# --- Extract deep profile data ---
top_functions = _extract_function_stats(profiler)
categories = _extract_category_breakdown(top_functions)
all_runs.append({
"run": i + 1,
"init_ms": round(init_ms, 2),
"execution_ms": round(exec_ms, 2),
"total_ms": round(total_ms, 2),
"response_length": len(str(result)) if result else 0,
"top_functions": top_functions,
"categories": categories,
})
# --- Aggregate ---
exec_times = [r["execution_ms"] for r in all_runs]
init_times = [r["init_ms"] for r in all_runs]
total_times = [r["total_ms"] for r in all_runs]
# Average the category breakdowns
avg_categories = {}
for key in ["network_ms", "imports_ms", "framework_ms"]:
vals = [r["categories"].get(key, 0) for r in all_runs]
avg_categories[key] = round(mean(vals), 2)
return {
"prompt_label": prompt[:60] + "..." if len(prompt) > 60 else prompt,
"model": model,
"runs": runs,
"profile_type": "deep",
"individual_runs": all_runs,
"execution_ms": {
"mean": round(mean(exec_times), 2),
"median": round(median(exec_times), 2),
"stdev": round(stdev(exec_times), 2) if len(exec_times) > 1 else 0,
"min": round(min(exec_times), 2),
"max": round(max(exec_times), 2),
},
"init_ms": {
"mean": round(mean(init_times), 2),
"median": round(median(init_times), 2),
},
"total_ms": {
"mean": round(mean(total_times), 2),
"median": round(median(total_times), 2),
},
"avg_categories": avg_categories,
# Store the top functions from the last run for display
"top_functions_snapshot": all_runs[-1]["top_functions"][:20],
}
def _save_result(record: Dict[str, Any]) -> Dict[str, Any]:
"""Append a profiling record to the JSONL history file."""
_ensure_results_dir()
record["timestamp"] = datetime.now(timezone.utc).isoformat()
record["version"] = _get_version()
record["python"] = platform.python_version()
record["platform"] = platform.system()
record["machine"] = platform.machine()
with open(HISTORY_FILE, "a") as f:
f.write(json.dumps(record, default=str) + "\n")
return record
def _load_history() -> List[Dict[str, Any]]:
"""Load all historical profiling records."""
if not HISTORY_FILE.exists():
return []
records = []
for line in HISTORY_FILE.read_text().strip().split("\n"):
if line.strip():
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def _print_deep_report(label: str, result: Dict, history: List[Dict], model: str):
"""Print a deep profile report with function-level breakdown."""
# Filter matching history
matching = [
r for r in history
if r.get("model") == model
and r.get("prompt_label") == result.get("prompt_label")
and r.get("profile_type") == "deep"
]
print(f"\n{'=' * 70}")
print(f" DEEP PROFILE: {label}")
print(f"{'=' * 70}")
print(f" Model: {model}")
print(f" Runs: {result['runs']}")
print(f" Exec: {result['execution_ms']['mean']:.0f}ms "
f"(±{result['execution_ms']['stdev']:.0f}ms)")
print(f" Init: {result['init_ms']['mean']:.1f}ms")
print(f" Total: {result['total_ms']['mean']:.0f}ms")
# Category breakdown
cats = result.get("avg_categories", {})
print(f"\n Category Breakdown:")
print(f" Network (SSL/recv): {cats.get('network_ms', 0):.0f}ms")
print(f" Imports: {cats.get('imports_ms', 0):.0f}ms")
print(f" Framework: {cats.get('framework_ms', 0):.0f}ms")
# Top functions
top_fns = result.get("top_functions_snapshot", [])
if top_fns:
print(f"\n Top Functions by Cumulative Time:")
print(f" {'Function':<45} {'Calls':>6} {'Cumul (ms)':>12}")
print(f" {'-' * 65}")
for fn in top_fns[:15]:
name = fn["name"][:44]
print(f" {name:<45} {fn['calls']:>6} {fn['cumulative_ms']:>11.2f}")
# Historical comparison
if matching:
prev = matching[-1]
prev_mean = prev["execution_ms"]["mean"]
curr_mean = result["execution_ms"]["mean"]
delta = curr_mean - prev_mean
pct = (delta / prev_mean * 100) if prev_mean > 0 else 0
emoji = "🟢" if delta <= 0 else ("🔴" if pct > 20 else "🟡")
print(f"\n {emoji} vs previous ({prev.get('timestamp', '?')[:10]}):")
print(f" Previous: {prev_mean:.0f}ms → Current: {curr_mean:.0f}ms "
f"({delta:+.0f}ms, {pct:+.1f}%)")
if len(matching) >= 3:
all_means = [r["execution_ms"]["mean"] for r in matching[-10:]]
print(f"\n 📊 Last {len(all_means)} runs: "
f"avg={mean(all_means):.0f}ms, "
f"range={min(all_means):.0f}-{max(all_means):.0f}ms")
else:
print(f"\n ℹ️ First deep profile — no history to compare")
print()
# ---------------------------------------------------------------------------
# Pytest Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY required for live profiling"
)
class TestDeepPerformanceProfile:
"""Live deep performance profiling tests with result storage."""
def test_simple_prompt_deep_profile(self):
"""Deep profile a simple prompt and store results."""
model = DEFAULT_MODEL
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=3)
_save_result({"test": "simple_prompt", **result})
history = _load_history()
_print_deep_report("Simple Prompt", result, history, model)
# Sanity checks
assert result["execution_ms"]["mean"] > 0, "Execution should take time"
assert result["init_ms"]["mean"] < 2000, "Init should be under 2s"
assert result.get("top_functions_snapshot"), "Should have function stats"
assert all(r["response_length"] > 0 for r in result["individual_runs"]), \
"All runs should produce output"
def test_complex_prompt_deep_profile(self):
"""Deep profile a complex prompt and store results."""
model = DEFAULT_MODEL
result = _run_deep_profile(COMPLEX_PROMPT, model=model, runs=3)
_save_result({"test": "complex_prompt", **result})
history = _load_history()
_print_deep_report("Complex Prompt", result, history, model)
assert result["execution_ms"]["mean"] > 0
assert result.get("top_functions_snapshot")
def test_multi_model_deep_comparison(self):
"""Deep profile across multiple models (set PROFILE_MODELS env var)."""
models = os.environ.get("PROFILE_MODELS", DEFAULT_MODEL).split(",")
models = [m.strip() for m in models if m.strip()]
for model in models:
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=2)
_save_result({"test": "model_comparison", **result})
history = _load_history()
_print_deep_report(f"Model: {model}", result, history, model)
assert result["execution_ms"]["mean"] > 0
# ---------------------------------------------------------------------------
# CLI: Direct execution
# ---------------------------------------------------------------------------
def main():
"""Run deep profiles directly and show comparison."""
import argparse
parser = argparse.ArgumentParser(description="Deep performance profiling tool")
parser.add_argument("--compare", action="store_true",
help="Show comparison of historical results only")
parser.add_argument("--model", default=DEFAULT_MODEL,
help=f"Model to profile (default: {DEFAULT_MODEL})")
parser.add_argument("--runs", type=int, default=3,
help="Number of runs per prompt (default: 3)")
args = parser.parse_args()
if args.compare:
history = _load_history()
if not history:
print("No history found. Run profiles first.")
return
# Group by test + model
groups: Dict[str, List] = {}
for r in history:
if r.get("profile_type") != "deep":
continue
key = f"{r.get('test', 'unknown')}|{r.get('model', 'unknown')}"
groups.setdefault(key, []).append(r)
for key, records in groups.items():
test_name, model = key.split("|")
print(f"\n{'=' * 70}")
print(f" {test_name} ({model}) — Deep Profile History")
print(f"{'=' * 70}")
print(f" {'Date':<22} {'Exec':>8} {'Init':>8} "
f"{'Network':>10} {'Imports':>10} Ver")
print(f" {'─' * 70}")
for r in records[-10:]:
ts = r.get("timestamp", "")[:19]
ex = r.get("execution_ms", {})
cats = r.get("avg_categories", {})
ver = r.get("version", "?")
print(f" {ts:<22} {ex.get('mean', 0):>7.0f}ms"
f" {r.get('init_ms', {}).get('mean', 0):>7.1f}ms"
f" {cats.get('network_ms', 0):>9.0f}ms"
f" {cats.get('imports_ms', 0):>9.0f}ms"
f" {ver}")
# Trend
if len(records) >= 2:
first_mean = records[0]["execution_ms"]["mean"]
last_mean = records[-1]["execution_ms"]["mean"]
delta = last_mean - first_mean
pct = (delta / first_mean * 100) if first_mean > 0 else 0
emoji = "📈" if delta > 0 else "📉"
print(f"\n {emoji} Trend: {delta:+.0f}ms ({pct:+.1f}%) "
f"over {len(records)} runs")
return
# Run deep profiles
print("=" * 70)
print(" DEEP PERFORMANCE PROFILING")
print(f" Model: {args.model} | Runs: {args.runs} | cProfile: ON")
print("=" * 70)
prompts = {
"simple_prompt": SIMPLE_PROMPT,
"complex_prompt": COMPLEX_PROMPT,
}
for test_name, prompt in prompts.items():
print(f"\n▸ Running deep profile: {test_name}...")
result = _run_deep_profile(prompt, model=args.model, runs=args.runs)
_save_result({"test": test_name, **result})
history = _load_history()
_print_deep_report(test_name.replace("_", " ").title(), result, history, args.model)
print(f"\n✅ Deep profile results stored in: {HISTORY_FILE}")
print(" Run with --compare to see trends over time.")
if __name__ == "__main__":
main()
@@ -57,7 +57,6 @@ class TestAgentRealAPI:
name="TestAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="gpt-4o-mini",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
@@ -82,7 +81,6 @@ class TestAgentRealAPI:
instructions="You are a math assistant. Use the add_numbers tool when asked to add.",
llm="gpt-4o-mini",
tools=[add_numbers],
verbose=False
)
response = agent.chat("What is 5 + 3? Use the add_numbers tool.")
@@ -100,7 +98,6 @@ class TestAgentRealAPI:
name="HistoryAgent",
instructions="You are a helpful assistant. Remember what the user tells you.",
llm="gpt-4o-mini",
verbose=False
)
# First message
@@ -189,7 +186,6 @@ class TestAnthropicAPI:
name="ClaudeAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="claude-3-5-sonnet-20241022",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
@@ -223,7 +219,6 @@ class TestGoogleAPI:
name="GeminiAgent",
instructions="You are a helpful assistant. Keep responses very brief.",
llm="gemini/gemini-2.0-flash",
verbose=False
)
response = agent.chat("Say 'hello' and nothing else.")
@@ -0,0 +1,368 @@
"""
Integration tests for the OpenAI Responses API implementation.
These tests verify that the Responses API is correctly wired into the LLM class
and that agents work end-to-end with both the Responses API path (Path P1) and
the Chat Completions path (P2 / fallback).
Usage:
RUN_REAL_KEY_TESTS=1 pytest tests/integration/test_responses_api.py -v -s
Required environment variables:
- OPENAI_API_KEY: OpenAI API key
"""
import os
import sys
import time
import pytest
# Note: Only real API tests are gated by RUN_REAL_KEY_TESTS.
# Unit-level tests (model detection, param building, extraction) always run.
_skip_real = pytest.mark.skipif(
os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'),
reason="Real API key tests disabled. Set RUN_REAL_KEY_TESTS=1 to enable."
)
def clear_modules():
"""Clear all praisonai and litellm related modules from cache."""
to_remove = [m for m in list(sys.modules.keys())
if 'praison' in m or 'litellm' in m]
for mod in to_remove:
del sys.modules[mod]
# ── Unit-level checks (no API key needed for these) ────────────────────
class TestResponsesAPIModelDetection:
"""Verify _supports_responses_api() returns correct values."""
def test_openai_models_detected(self):
from praisonaiagents.llm.llm import LLM
positive_models = [
"gpt-4o", "gpt-4o-mini", "gpt-4o-2024-08-06",
"gpt-4-turbo", "gpt-4-turbo-preview",
"gpt-4.1", "gpt-4.5-preview",
"o1-preview", "o1-mini", "o3-mini", "o4-mini",
"chatgpt-4o-latest",
"azure/gpt-4o", "azure/o3-mini",
"openai/gpt-4o",
]
for model in positive_models:
llm = LLM(model=model)
assert llm._supports_responses_api(), f"{model} should support Responses API"
def test_non_openai_models_rejected(self):
from praisonaiagents.llm.llm import LLM
negative_models = [
"claude-3-opus-20240229", "claude-3-5-sonnet-20241022",
"anthropic/claude-3-haiku",
"ollama/llama3",
"gemini/gemini-pro", "gemini/gemini-1.5-flash",
"groq/llama-3.1-70b-versatile",
"together_ai/mistral-7b",
"deepseek/deepseek-chat",
"gpt-3.5-turbo", # Too old for Responses API
]
for model in negative_models:
llm = LLM(model=model)
assert not llm._supports_responses_api(), f"{model} should NOT support Responses API"
class TestResponsesAPIParamBuilder:
"""Verify _build_responses_params() correctly translates Chat Completions messages."""
def test_system_to_instructions(self):
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o")
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
]
params = llm._build_responses_params(messages=messages, temperature=0.7)
assert params["model"] == "gpt-4o"
assert params["instructions"] == "You are a helpful assistant."
assert len(params["input"]) == 1
assert params["input"][0]["role"] == "user"
assert params["temperature"] == 0.7
def test_tools_transformed_to_responses_format(self):
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o")
# Chat Completions format: nested under "function"
tools = [{"type": "function", "function": {"name": "add", "description": "Add nums", "parameters": {}}}]
params = llm._build_responses_params(messages=[{"role": "user", "content": "hi"}], tools=tools)
# Should be transformed to Responses API format: flattened
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add nums", "parameters": {}}]
assert params["tool_choice"] == "auto"
class TestResponsesAPIOutputExtraction:
"""Verify _extract_from_responses_output() correctly parses output items."""
def _make_mock_response(self, output_items):
class MockResponse:
def __init__(self, output):
self.output = output
return MockResponse(output_items)
def test_text_only(self):
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o")
resp = self._make_mock_response([
{"type": "message", "content": [{"type": "output_text", "text": "Hello!"}]}
])
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
assert text == "Hello!"
assert tool_calls is None
assert reasoning is None
def test_text_plus_tool_calls(self):
"""The key test: text and tool calls coexist without content:null."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o")
resp = self._make_mock_response([
{"type": "message", "content": [{"type": "output_text", "text": "Let me calculate."}]},
{"type": "function_call", "call_id": "call_123", "name": "add", "arguments": '{"a":2,"b":2}'},
])
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
assert text == "Let me calculate."
assert len(tool_calls) == 1
assert tool_calls[0]["function"]["name"] == "add"
assert tool_calls[0]["id"] == "call_123"
def test_multiple_tool_calls(self):
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o")
resp = self._make_mock_response([
{"type": "function_call", "call_id": "c1", "name": "search", "arguments": '{"q":"a"}'},
{"type": "function_call", "call_id": "c2", "name": "fetch", "arguments": '{"url":"b"}'},
])
text, tool_calls, _ = llm._extract_from_responses_output(resp)
assert text == ""
assert len(tool_calls) == 2
# ── Real API integration tests ─────────────────────────────────────────
@_skip_real
class TestResponsesAPIRealAgent:
"""End-to-end agent tests using real OpenAI API calls."""
@pytest.fixture(autouse=True)
def setup(self):
clear_modules()
api_key = os.environ.get('OPENAI_API_KEY')
if not api_key:
pytest.skip("OPENAI_API_KEY not set")
yield
clear_modules()
def test_responses_api_path_with_slash_model(self):
"""
Test with 'openai/gpt-4o-mini' which routes through LLM class (Path P1)
and uses the Responses API.
"""
from praisonaiagents import Agent
agent = Agent(
name="ResponsesAPIAgent",
instructions="You are a helpful assistant. Keep responses brief.",
llm="openai/gpt-4o-mini",
)
response = agent.start(
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
"1) Search the web for each framework's latest version and key features, "
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
"3) Execute the code to verify the dictionary is valid, "
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
"5) Read back the report file to verify it was written correctly, "
"6) List the files in /tmp to confirm both files exist, "
"7) Get system info to note what OS this report was generated on, "
"8) Analyze the Python code you wrote for any issues, "
"9) Format the Python code properly, "
"10) Finally search for any recent news about Python 3.13 features"
)
assert response is not None
assert len(response) > 100, f"Response too short ({len(response)} chars)"
print(f"[PASS] openai/gpt-4o-mini - {len(response)} chars")
def test_chat_completions_path_with_bare_model(self):
"""
Test with 'gpt-4o-mini' which routes through OpenAIClient (Path P2)
and uses the standard Chat Completions API.
"""
from praisonaiagents import Agent
agent = Agent(
name="ChatCompletionsAgent",
instructions="You are a helpful assistant. Keep responses brief.",
llm="gpt-4o-mini",
)
response = agent.start(
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
"1) Search the web for each framework's latest version and key features, "
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
"3) Execute the code to verify the dictionary is valid, "
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
"5) Read back the report file to verify it was written correctly, "
"6) List the files in /tmp to confirm both files exist, "
"7) Get system info to note what OS this report was generated on, "
"8) Analyze the Python code you wrote for any issues, "
"9) Format the Python code properly, "
"10) Finally search for any recent news about Python 3.13 features"
)
assert response is not None
assert len(response) > 100, f"Response too short ({len(response)} chars)"
print(f"[PASS] gpt-4o-mini - {len(response)} chars")
def test_responses_api_with_tool(self):
"""
Test that Responses API correctly handles tool calls — the key
scenario where content:null was previously a problem.
"""
from praisonaiagents import Agent
def add_numbers(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
agent = Agent(
name="ToolAgent",
instructions="You are a math assistant. Use the add_numbers tool.",
llm="openai/gpt-4o-mini",
tools=[add_numbers],
)
response = agent.chat("What is 15 + 27? Use the tool.")
assert response is not None
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
print(f"[PASS] Tool call via Responses API (Path P1) - response contains '42'")
def test_openai_client_path_with_tool(self):
"""
Test bare model (gpt-4o-mini) with tool via OpenAIClient (Path P2) —
now using Responses API. This is the key test for the content:null fix
on the OpenAIClient path.
"""
from praisonaiagents import Agent
def add_numbers(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
agent = Agent(
name="ToolAgentP2",
instructions="You are a math assistant. Use the add_numbers tool.",
model="gpt-4o-mini", # bare model → OpenAIClient path
tools=[add_numbers],
)
response = agent.chat("What is 15 + 27? Use the tool.")
assert response is not None
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
print(f"[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
# ── OpenAIClient-specific unit tests ───────────────────────────────────
class TestOpenAIClientResponsesAPI:
"""Verify OpenAIClient Responses API helper methods."""
def test_supports_responses_api_static(self):
from praisonaiagents.llm.openai_client import OpenAIClient
# Positive cases
assert OpenAIClient._supports_responses_api("gpt-4o-mini")
assert OpenAIClient._supports_responses_api("gpt-4o")
assert OpenAIClient._supports_responses_api("gpt-4-turbo")
assert OpenAIClient._supports_responses_api("o3-mini")
assert OpenAIClient._supports_responses_api("gpt-4.1")
assert OpenAIClient._supports_responses_api("chatgpt-4o-latest")
# Negative cases
assert not OpenAIClient._supports_responses_api("claude-3-opus")
assert not OpenAIClient._supports_responses_api("ollama/llama3")
assert not OpenAIClient._supports_responses_api("gemini/gemini-pro")
assert not OpenAIClient._supports_responses_api("deepseek/deepseek-chat")
assert not OpenAIClient._supports_responses_api("")
def test_build_responses_input(self):
from praisonaiagents.llm.openai_client import OpenAIClient
client = OpenAIClient.__new__(OpenAIClient) # skip __init__
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
]
tools = [{"type": "function", "function": {"name": "add", "description": "Add", "parameters": {}}}]
params = client._build_responses_input(messages, "gpt-4o-mini", 0.5, tools)
assert params["model"] == "gpt-4o-mini"
assert params["instructions"] == "You are helpful."
assert len(params["input"]) == 1 # only user msg
assert params["input"][0]["role"] == "user"
assert params["temperature"] == 0.5
# Tools should be in Responses API format (flattened)
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add", "parameters": {}}]
def test_responses_to_chat_completion(self):
from praisonaiagents.llm.openai_client import OpenAIClient
client = OpenAIClient.__new__(OpenAIClient)
class MockItem:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class MockResponse:
id = "resp_test"
model = "gpt-4o-mini"
output = [
MockItem(
type="message",
content=[MockItem(type="output_text", text="Hello!")]
),
MockItem(
type="function_call",
call_id="call_1",
name="add",
arguments='{"a":1,"b":2}'
),
]
usage = MockItem(input_tokens=10, output_tokens=5, total_tokens=15)
result = client._responses_to_chat_completion(MockResponse())
assert result.id == "resp_test"
assert result.choices[0].message.content == "Hello!"
assert result.choices[0].message.tool_calls is not None
assert len(result.choices[0].message.tool_calls) == 1
assert result.choices[0].message.tool_calls[0].function["name"] == "add"
assert result.choices[0].finish_reason == "tool_calls"
if __name__ == "__main__":
if os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'):
print("Set RUN_REAL_KEY_TESTS=1 to run real API tests")
print("Running unit tests only...\n")
pytest.main([__file__, "-v", "-s", "-k", "not Real"])
else:
pytest.main([__file__, "-v", "-s"])
@@ -88,7 +88,6 @@ class TestAgentKnowledgeRetrieval:
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
knowledge=[temp_knowledge_dir],
user_id="live_test_user",
verbose=False,
)
response = agent.chat("What is the project code for Phoenix Initiative?")
@@ -105,7 +104,6 @@ class TestAgentKnowledgeRetrieval:
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
knowledge=[temp_knowledge_dir],
user_id="live_test_user",
verbose=False,
)
response = agent.chat("What is the budget code?")
@@ -122,7 +120,6 @@ class TestAgentKnowledgeRetrieval:
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
knowledge=[temp_knowledge_dir],
user_id="live_test_user",
verbose=False,
)
response = agent.chat("What is the security code?")
@@ -0,0 +1,38 @@
"""
Real agentic test for store_learning/search_learning tools.
Agent actually calls LLM and uses the tools end-to-end.
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from praisonaiagents import Agent
from praisonaiagents.tools import store_learning, search_learning
agent = Agent(
name="LearningBot",
instructions=(
"You are a helpful assistant that learns user preferences. "
"When the user tells you a preference, use store_learning with the appropriate category. "
"When asked to recall, use search_learning."
),
memory=True,
learn=True,
tools=[store_learning, search_learning],
llm="gpt-4o-mini",
)
# Turn 1: Store a learning
result1 = agent.start("I always prefer bullet-point answers. Remember this as a persona preference.")
print(f"Turn 1 result: {result1}")
# Turn 2: Recall the learning
result2 = agent.start("What are my preferences?")
print(f"Turn 2 result: {result2}")
# Simple check
passed = "bullet" in str(result2).lower()
if passed:
print("\n✅ Real agentic learning test passed!")
else:
print("\n⚠️ Agent did not recall learning, but tools are wired correctly.")
print(" (This may happen if LLM doesn't use search_learning; passive learn also works.)")
@@ -0,0 +1,41 @@
"""
Real agentic test for memory tools.
This test creates an Agent with memory=True and tools=[store_memory, search_memory],
calls agent.start() with a real prompt, and verifies the agent actually calls the LLM.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import store_memory, search_memory
def test_memory_tools_real_agent():
"""Agent with memory tools can be created and started."""
agent = Agent(
name="MemoryTestAgent",
instructions=(
"You are a helpful assistant with memory. "
"When the user tells you something to remember, use store_memory. "
"When the user asks about something previously stored, use search_memory."
),
memory=True,
tools=[store_memory, search_memory],
)
# Turn 1: Ask the agent to store something
result = agent.start("Remember that my favorite color is blue. Say OK when done.")
print(f"Turn 1 result: {result}")
assert result is not None
assert len(str(result)) > 0
# Turn 2: Ask the agent to recall
result2 = agent.start("What is my favorite color? Search your memory if needed.")
print(f"Turn 2 result: {result2}")
assert result2 is not None
assert len(str(result2)) > 0
print("\n✅ Real agentic test passed!")
if __name__ == "__main__":
test_memory_tools_real_agent()
@@ -0,0 +1,387 @@
"""Unit tests for email_tools.py — mocks AgentMail SDK, no real API calls."""
import os
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
# Ensure the SDK is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
class TestEmailTools(unittest.TestCase):
"""Tests for send_email, list_emails, read_email, list_inboxes."""
def setUp(self):
"""Reset module-level client cache before each test."""
import praisonaiagents.tools.email_tools as et
et._client = None
os.environ["AGENTMAIL_API_KEY"] = "am_test_key"
os.environ["AGENTMAIL_INBOX_ID"] = "[email protected]"
def tearDown(self):
import praisonaiagents.tools.email_tools as et
et._client = None
os.environ.pop("AGENTMAIL_API_KEY", None)
os.environ.pop("AGENTMAIL_INBOX_ID", None)
def _mock_agentmail_module(self):
"""Create a mock agentmail module with AgentMail class."""
mock_client = MagicMock()
mock_agentmail_cls = MagicMock(return_value=mock_client)
mock_module = types.ModuleType("agentmail")
mock_module.AgentMail = mock_agentmail_cls
return mock_module, mock_client
# ── send_email ──────────────────────────────────────────────
def test_send_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
# Mock the send response
mock_result = MagicMock()
mock_result.message_id = "msg_123"
mock_result.thread_id = "thread_456"
mock_client.inboxes.messages.send.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None # Force re-creation
result = send_email(to="[email protected]", subject="Hello", body="Hi there")
self.assertIn("sent successfully", result)
self.assertIn("msg_123", result)
mock_client.inboxes.messages.send.assert_called_once_with(
"[email protected]",
to="[email protected]",
subject="Hello",
text="Hi there",
)
def test_send_email_uses_text_not_body(self):
"""Critical: SDK uses text= not body= parameter."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "msg_1"
mock_result.thread_id = "t1"
mock_client.inboxes.messages.send.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None
send_email(to="[email protected]", subject="s", body="b")
call_kwargs = mock_client.inboxes.messages.send.call_args
# Must use text= keyword, never body=
self.assertIn("text", call_kwargs.kwargs)
self.assertNotIn("body", call_kwargs.kwargs)
def test_send_email_failure_returns_error(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_client.inboxes.messages.send.side_effect = Exception("Network error")
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = send_email(to="[email protected]", subject="s", body="b")
self.assertIn("Failed to send", result)
self.assertIn("Network error", result)
# ── list_emails ─────────────────────────────────────────────
def test_list_emails_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.subject = "Test Subject"
mock_msg.preview = "Preview text"
mock_msg.message_id = "msg_100"
mock_msg.timestamp = "2026-03-16"
mock_response = MagicMock()
mock_response.messages = [mock_msg]
mock_response.count = 1
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails(limit=5)
self.assertIn("[email protected]", result)
self.assertIn("Test Subject", result)
self.assertIn("msg_100", result)
# Must use .messages attribute, not iterate response directly
mock_client.inboxes.messages.list.assert_called_once_with(
"[email protected]", limit=5
)
def test_list_emails_uses_from_underscore(self):
"""Critical: SDK uses msg.from_ not msg.from_address."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock(spec=["from_", "subject", "preview", "message_id", "timestamp"])
mock_msg.from_ = "[email protected]"
mock_msg.subject = "Sub"
mock_msg.preview = "Pre"
mock_msg.message_id = "id1"
mock_msg.timestamp = "now"
mock_response = MagicMock()
mock_response.messages = [mock_msg]
mock_response.count = 1
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails()
self.assertIn("[email protected]", result)
def test_list_emails_empty(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_response = MagicMock()
mock_response.messages = []
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails()
self.assertIn("No emails found", result)
# ── read_email ──────────────────────────────────────────────
def test_read_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.to = ["[email protected]"]
mock_msg.subject = "Full Read Test"
mock_msg.extracted_text = "Full body content here"
mock_msg.timestamp = "2026-03-16T12:00:00"
mock_msg.in_reply_to = ""
mock_client.inboxes.messages.get.return_value = mock_msg
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import read_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = read_email(message_id="msg_100")
self.assertIn("[email protected]", result)
self.assertIn("Full Read Test", result)
self.assertIn("Full body content here", result)
# ── list_inboxes ────────────────────────────────────────────
def test_list_inboxes_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_inbox = MagicMock()
mock_inbox.inbox_id = "[email protected]"
mock_inbox.display_name = "Praison"
mock_response = MagicMock()
mock_response.inboxes = [mock_inbox]
mock_client.inboxes.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_inboxes
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_inboxes()
self.assertIn("[email protected]", result)
self.assertIn("Praison", result)
# ── Missing env vars ────────────────────────────────────────
def test_missing_api_key_raises(self):
os.environ.pop("AGENTMAIL_API_KEY", None)
import praisonaiagents.tools.email_tools as et
et._client = None
with self.assertRaises(ValueError) as ctx:
et._get_client()
self.assertIn("AGENTMAIL_API_KEY", str(ctx.exception))
def test_missing_inbox_id_raises(self):
os.environ.pop("AGENTMAIL_INBOX_ID", None)
import praisonaiagents.tools.email_tools as et
with self.assertRaises(ValueError) as ctx:
et._get_inbox_id()
self.assertIn("AGENTMAIL_INBOX_ID", str(ctx.exception))
# ── Profile registration ────────────────────────────────────
def test_email_profile_exists(self):
from praisonaiagents.tools.profiles import get_profile
profile = get_profile("email")
self.assertEqual(profile.name, "email")
self.assertIn("send_email", profile.tools)
self.assertIn("list_emails", profile.tools)
self.assertIn("read_email", profile.tools)
# ── Lazy import via tools package ───────────────────────────
def test_lazy_import_from_tools_package(self):
from praisonaiagents.tools import send_email
from praisonaiagents.tools.email_tools import send_email as direct
self.assertIs(send_email, direct)
# ── reply_email ──────────────────────────────────────────────
def test_reply_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "reply_msg_1"
mock_result.thread_id = "thread_1"
mock_client.inboxes.messages.reply.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import reply_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = reply_email(message_id="msg_100", body="Thanks!")
self.assertIn("reply_msg_1", result)
self.assertIn("Reply sent", result)
mock_client.inboxes.messages.reply.assert_called_once_with(
"[email protected]",
"msg_100",
text="Thanks!",
)
def test_reply_email_uses_text_not_body(self):
"""SDK reply uses text= not body= parameter."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "r1"
mock_result.thread_id = "t1"
mock_client.inboxes.messages.reply.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import reply_email
import praisonaiagents.tools.email_tools as et
et._client = None
reply_email(message_id="m1", body="b")
call_kwargs = mock_client.inboxes.messages.reply.call_args
self.assertIn("text", call_kwargs.kwargs)
self.assertNotIn("body", call_kwargs.kwargs)
# ── create_inbox ─────────────────────────────────────────────
def test_create_inbox_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_inbox = MagicMock()
mock_inbox.inbox_id = "[email protected]"
mock_inbox.display_name = "My Agent"
mock_client.inboxes.create.return_value = mock_inbox
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import create_inbox
import praisonaiagents.tools.email_tools as et
et._client = None
result = create_inbox(display_name="My Agent")
self.assertIn("[email protected]", result)
self.assertIn("created", result.lower())
# ── Angle-bracket normalization ──────────────────────────────
def test_read_email_angle_bracket_normalization(self):
"""LLMs strip angle brackets from message IDs. read_email should add them."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.to = ["[email protected]"]
mock_msg.subject = "Test"
mock_msg.extracted_text = "Body"
mock_msg.timestamp = "now"
mock_msg.in_reply_to = ""
mock_client.inboxes.messages.get.return_value = mock_msg
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import read_email
import praisonaiagents.tools.email_tools as et
et._client = None
# Pass WITHOUT angle brackets — should be normalized
read_email(message_id="[email protected]")
# The API call should have angle brackets
call_args = mock_client.inboxes.messages.get.call_args
actual_id = call_args[0][1] # second positional arg
self.assertTrue(actual_id.startswith("<"), f"Expected <...> but got: {actual_id}")
self.assertTrue(actual_id.endswith(">"), f"Expected <...> but got: {actual_id}")
# ── SMTP tools error handling ────────────────────────────────
def test_smtp_send_email_missing_creds(self):
os.environ.pop("EMAIL_ADDRESS", None)
os.environ.pop("EMAIL_PASSWORD", None)
from praisonaiagents.tools.email_tools import smtp_send_email
result = smtp_send_email(to="[email protected]", subject="s", body="b")
self.assertIn("EMAIL_ADDRESS", result)
self.assertIn("EMAIL_PASSWORD", result)
def test_smtp_read_inbox_missing_creds(self):
os.environ.pop("EMAIL_ADDRESS", None)
os.environ.pop("EMAIL_PASSWORD", None)
from praisonaiagents.tools.email_tools import smtp_read_inbox
result = smtp_read_inbox()
self.assertIn("EMAIL_ADDRESS", result)
self.assertIn("EMAIL_PASSWORD", result)
# ── SMTP profile registration ────────────────────────────────
def test_smtp_email_profile_exists(self):
from praisonaiagents.tools.profiles import get_profile
profile = get_profile("smtp_email")
self.assertEqual(profile.name, "smtp_email")
self.assertIn("smtp_send_email", profile.tools)
self.assertIn("smtp_read_inbox", profile.tools)
# ── SMTP lazy import ─────────────────────────────────────────
def test_smtp_lazy_import_from_tools_package(self):
from praisonaiagents.tools import smtp_send_email
from praisonaiagents.tools.email_tools import smtp_send_email as direct
self.assertIs(smtp_send_email, direct)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,248 @@
"""
TDD tests for autonomy mode redesign.
Tests the new `mode` field in AutonomyConfig and the caller-mode behavior
where autonomy=True does a single chat() call instead of the internal loop.
"""
import pytest
from unittest.mock import patch, MagicMock
# ============================================================================
# 1. AutonomyConfig mode field tests
# ============================================================================
class TestAutonomyConfigMode:
"""Tests for the 'mode' field on AutonomyConfig."""
def test_mode_field_exists(self):
"""AutonomyConfig should have a mode field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert hasattr(config, 'mode'), "AutonomyConfig must have a 'mode' field"
def test_default_mode_is_caller_for_suggest(self):
"""Default level='suggest' should default to mode='caller'."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(level="suggest")
assert config.mode == "caller"
def test_default_mode_is_caller_for_auto_edit(self):
"""level='auto_edit' should default to mode='caller'."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(level="auto_edit")
assert config.mode == "caller"
def test_default_mode_is_iterative_for_full_auto(self):
"""level='full_auto' should default to mode='iterative' (backward compat)."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(level="full_auto")
assert config.mode == "iterative"
def test_explicit_mode_overrides_default(self):
"""Explicit mode should override the level-based default."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(level="full_auto", mode="caller")
assert config.mode == "caller"
def test_explicit_iterative_with_suggest(self):
"""Should be able to set mode='iterative' even with level='suggest'."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(level="suggest", mode="iterative")
assert config.mode == "iterative"
def test_invalid_mode_raises(self):
"""Invalid mode value should raise ValueError."""
from praisonaiagents.agent.autonomy import AutonomyConfig
with pytest.raises(ValueError, match="Invalid autonomy mode"):
AutonomyConfig(mode="invalid_mode")
def test_from_dict_with_mode(self):
"""AutonomyConfig.from_dict should parse mode field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig.from_dict({"mode": "iterative"})
assert config.mode == "iterative"
def test_from_dict_without_mode_uses_smart_default(self):
"""from_dict without mode should use smart default based on level."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig.from_dict({"level": "full_auto"})
assert config.mode == "iterative"
def test_from_dict_suggest_defaults_to_caller(self):
"""from_dict with suggest level defaults to caller mode."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig.from_dict({"level": "suggest"})
assert config.mode == "caller"
# ============================================================================
# 2. Agent _init_autonomy stores mode in autonomy_config dict
# ============================================================================
class TestAgentAutonomyModeInit:
"""Tests that Agent._init_autonomy correctly stores mode."""
def _make_agent(self, **autonomy_kwargs):
"""Helper to create a minimal agent with autonomy config."""
from praisonaiagents import Agent
return Agent(
name="test_mode",
instructions="test",
autonomy=autonomy_kwargs if autonomy_kwargs else True,
)
def test_autonomy_true_stores_mode(self):
"""Agent(autonomy=True) should store mode in autonomy_config."""
agent = self._make_agent()
assert "mode" in agent.autonomy_config, "autonomy_config must contain 'mode'"
def test_autonomy_true_default_mode_is_caller(self):
"""Agent(autonomy=True) defaults to level='suggest', so mode='caller'."""
agent = self._make_agent()
assert agent.autonomy_config["mode"] == "caller"
def test_autonomy_dict_full_auto_mode_iterative(self):
"""Agent(autonomy={'level':'full_auto'}) should default to mode='iterative'."""
agent = self._make_agent(level="full_auto")
assert agent.autonomy_config["mode"] == "iterative"
def test_autonomy_dict_explicit_mode(self):
"""Agent(autonomy={'mode':'iterative'}) should store explicit mode."""
agent = self._make_agent(mode="iterative")
assert agent.autonomy_config["mode"] == "iterative"
# ============================================================================
# 3. start() caller-mode behavior
# ============================================================================
class TestStartCallerMode:
"""Tests that start() in caller mode does a single chat() call."""
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_caller_mode_calls_chat_once(self, mock_chat):
"""In caller mode, start() should call chat() exactly once."""
mock_chat.return_value = "Hello! I'm here to help."
from praisonaiagents import Agent
agent = Agent(
name="test_caller",
instructions="You are helpful",
autonomy=True, # level=suggest → mode=caller
)
result = agent.start("Say hello")
mock_chat.assert_called_once()
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_caller_mode_does_not_call_run_autonomous(self, mock_chat):
"""In caller mode, start() must NOT call run_autonomous."""
mock_chat.return_value = "Done"
from praisonaiagents import Agent
agent = Agent(
name="test_no_loop",
instructions="You are helpful",
autonomy=True,
)
with patch.object(agent, 'run_autonomous') as mock_autonomous:
agent.start("Do something")
mock_autonomous.assert_not_called()
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_caller_mode_returns_autonomy_result(self, mock_chat):
"""In caller mode, start() should return AutonomyResult."""
mock_chat.return_value = "Task completed successfully"
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyResult
agent = Agent(
name="test_result",
instructions="You are helpful",
autonomy=True,
)
result = agent.start("Do task")
assert isinstance(result, AutonomyResult)
assert result.iterations == 1
assert result.success is True
assert result.completion_reason == "caller_mode"
assert "Task completed" in str(result)
@patch("praisonaiagents.agent.agent.Agent.chat")
def test_caller_mode_preserves_auto_approve(self, mock_chat):
"""Caller mode should still have auto-approve wired."""
mock_chat.return_value = "Done"
from praisonaiagents import Agent
agent = Agent(
name="test_approve",
instructions="test",
autonomy={"level": "auto_edit"},
)
# auto_edit should wire approval
assert agent.autonomy_enabled is True
assert agent.autonomy_config.get("mode") == "caller"
# ============================================================================
# 4. start() iterative-mode behavior (backward compat for full_auto)
# ============================================================================
class TestStartIterativeMode:
"""Tests that start() in iterative mode uses run_autonomous (backward compat)."""
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
def test_iterative_mode_calls_run_autonomous(self, mock_run):
"""In iterative mode, start() should call run_autonomous."""
from praisonaiagents.agent.autonomy import AutonomyResult
mock_run.return_value = AutonomyResult(
success=True, output="Done", completion_reason="goal",
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
)
from praisonaiagents import Agent
agent = Agent(
name="test_iterative",
instructions="test",
autonomy={"level": "full_auto"}, # defaults to iterative
)
agent.start("Do something complex")
mock_run.assert_called_once()
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
def test_explicit_iterative_mode_with_suggest(self, mock_run):
"""Explicit mode='iterative' should use run_autonomous."""
from praisonaiagents.agent.autonomy import AutonomyResult
mock_run.return_value = AutonomyResult(
success=True, output="Done", completion_reason="goal",
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
)
from praisonaiagents import Agent
agent = Agent(
name="test_explicit_iterative",
instructions="test",
autonomy={"level": "suggest", "mode": "iterative"},
)
agent.start("Ralph loop task")
mock_run.assert_called_once()
# ============================================================================
# 5. astart() caller-mode behavior (async parity)
# ============================================================================
class TestAstartCallerMode:
"""Tests that astart() in caller mode does a single achat() call."""
@pytest.mark.asyncio
async def test_caller_mode_achat_once(self):
"""In caller mode, astart() should call achat() exactly once."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyResult
agent = Agent(
name="test_async_caller",
instructions="You are helpful",
autonomy=True,
)
with patch.object(agent, 'achat', return_value="Hello async!") as mock_achat:
result = await agent.astart("Say hello")
mock_achat.assert_called_once()
assert isinstance(result, AutonomyResult)
assert result.iterations == 1
@@ -2,15 +2,10 @@
TDD tests for unified autonomy API.
Goal: Agent(autonomy=True) + agent.start("Task") should automatically
use the autonomous loop. No need to call run_autonomous() separately.
Current (redundant):
agent = Agent(autonomy=True)
result = agent.run_autonomous("Task") # Why two steps?
Proposed (unified):
agent = Agent(autonomy=True)
result = agent.start("Task") # Automatically uses autonomy loop!
use the appropriate mode based on config:
- level=suggest (default) → caller mode (single chat call)
- level=full_auto → iterative mode (run_autonomous loop)
- explicit mode overrides level-based default
"""
import pytest
@@ -21,16 +16,11 @@ class TestUnifiedAutonomyAPI:
"""Test that start() automatically uses autonomy when enabled."""
def test_start_uses_autonomy_when_enabled(self):
"""start() should route to run_autonomous when autonomy=True."""
"""start() should use caller mode (chat) when autonomy=True (default level=suggest)."""
from praisonaiagents import Agent
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
mock_run_auto.return_value = MagicMock(
success=True,
output="Task completed",
completion_reason="goal",
iterations=1,
)
with patch.object(Agent, 'chat') as mock_chat:
mock_chat.return_value = "Task completed"
agent = Agent(
name="test",
@@ -41,10 +31,11 @@ class TestUnifiedAutonomyAPI:
result = agent.start("Do something")
# Should have called run_autonomous, not chat
mock_run_auto.assert_called_once()
call_kwargs = mock_run_auto.call_args[1]
assert call_kwargs.get("prompt") == "Do something"
# With default level=suggest, should use caller mode (chat)
mock_chat.assert_called_once()
from praisonaiagents.agent.autonomy import AutonomyResult
assert isinstance(result, AutonomyResult)
assert result.completion_reason == "caller_mode"
def test_start_uses_chat_when_autonomy_disabled(self):
"""start() should use regular chat when autonomy is not enabled."""
@@ -67,18 +58,12 @@ class TestUnifiedAutonomyAPI:
mock_chat.assert_called()
def test_start_returns_autonomy_result_when_autonomy_enabled(self):
"""start() should return AutonomyResult when autonomy is enabled."""
"""start() should return AutonomyResult when autonomy is enabled (caller mode)."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyResult
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
mock_result = AutonomyResult(
success=True,
output="Done!",
completion_reason="goal",
iterations=2,
)
mock_run_auto.return_value = mock_result
with patch.object(Agent, 'chat') as mock_chat:
mock_chat.return_value = "Done!"
agent = Agent(
name="test",
@@ -94,7 +79,7 @@ class TestUnifiedAutonomyAPI:
assert result.output == "Done!"
def test_start_passes_autonomy_config_to_run_autonomous(self):
"""start() should pass autonomy config values to run_autonomous."""
"""start() should pass autonomy config values to run_autonomous in iterative mode."""
from praisonaiagents import Agent
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
@@ -107,6 +92,7 @@ class TestUnifiedAutonomyAPI:
"max_iterations": 15,
"completion_promise": "DONE",
"clear_context": True,
"mode": "iterative", # Explicit iterative mode
},
llm="gpt-4o-mini",
)
@@ -119,7 +105,7 @@ class TestUnifiedAutonomyAPI:
assert call_kwargs.get("clear_context") is True
def test_start_timeout_passed_to_run_autonomous(self):
"""start(timeout=X) should pass timeout to run_autonomous."""
"""start(timeout=X) should pass timeout to run_autonomous in iterative mode."""
from praisonaiagents import Agent
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
@@ -128,7 +114,7 @@ class TestUnifiedAutonomyAPI:
agent = Agent(
name="test",
instructions="Test agent",
autonomy=True,
autonomy={"mode": "iterative"}, # Must be iterative for run_autonomous
llm="gpt-4o-mini",
)
@@ -218,16 +204,12 @@ class TestAsyncUnifiedAPI:
@pytest.mark.asyncio
async def test_astart_uses_autonomy_when_enabled(self):
"""astart() should route to run_autonomous_async when autonomy=True."""
"""astart() should use caller mode (achat) when autonomy=True."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyResult
with patch.object(Agent, 'run_autonomous_async') as mock_run_auto:
mock_run_auto.return_value = MagicMock(
success=True,
output="Task completed",
completion_reason="goal",
iterations=1,
)
with patch.object(Agent, 'achat') as mock_achat:
mock_achat.return_value = "Task completed"
agent = Agent(
name="test",
@@ -238,7 +220,8 @@ class TestAsyncUnifiedAPI:
result = await agent.astart("Do something")
mock_run_auto.assert_called_once()
mock_achat.assert_called_once()
assert isinstance(result, AutonomyResult)
@pytest.mark.asyncio
async def test_astart_uses_achat_when_autonomy_disabled(self):
@@ -264,7 +247,7 @@ class TestCLISimplification:
"""Test that CLI can use simplified API."""
def test_cli_can_use_start_for_loop(self):
"""CLI loop command should be able to use agent.start() directly."""
"""CLI loop command should be able to use agent.start() in iterative mode."""
from praisonaiagents import Agent
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
@@ -277,13 +260,14 @@ class TestCLISimplification:
started_at="2024-01-01T00:00:00Z",
)
# This is what CLI should do - simple!
# CLI loop must explicitly set mode="iterative"
agent = Agent(
name="loop_agent",
instructions="Complete the task",
autonomy={
"max_iterations": 10,
"completion_promise": "DONE",
"mode": "iterative", # Explicit for loop
},
llm="gpt-4o-mini",
)
@@ -397,5 +397,57 @@ class TestBackgroundRunner:
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
# =============================================================================
# BackgroundRunner Sync-Wrapper Tests
# =============================================================================
class TestBackgroundRunnerSync:
"""Tests for submit_sync / submit_agent_sync methods."""
def test_submit_sync_function(self):
"""submit_sync runs a plain function and returns a trackable task."""
runner = BackgroundRunner()
def double(x):
return x * 2
task = runner.submit_sync(func=double, args=(7,), name="sync-double")
assert task is not None
assert task.name == "sync-double"
# Wait for the result (polling).
import time
for _ in range(50):
if task.is_completed:
break
time.sleep(0.1)
assert task.is_successful
assert task.result == 14
def test_submit_agent_sync(self):
"""submit_agent_sync dispatches a mock agent start()."""
runner = BackgroundRunner()
class FakeAgent:
name = "faker"
def start(self, prompt):
return f"echo: {prompt}"
agent = FakeAgent()
task = runner.submit_agent_sync(agent, "hello", name="sync-agent")
assert task is not None
import time
for _ in range(50):
if task.is_completed:
break
time.sleep(0.1)
assert task.is_successful
assert task.result == "echo: hello"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,227 @@
"""
TDD tests for TOOL_CALL_START and TOOL_CALL_RESULT stream events.
These events provide complete parsed arguments (dict) for tool calls,
enabling consumers like AIUI to display keyword-rich descriptions.
"""
class TestStreamEventTypeEnumValues:
"""Test that new StreamEventType values exist."""
def test_tool_call_start_event_type_exists(self):
"""Verify TOOL_CALL_START enum value exists."""
from praisonaiagents.streaming.events import StreamEventType
assert hasattr(StreamEventType, 'TOOL_CALL_START')
assert StreamEventType.TOOL_CALL_START.value == "tool_call_start"
def test_tool_call_result_event_type_exists(self):
"""Verify TOOL_CALL_RESULT enum value exists."""
from praisonaiagents.streaming.events import StreamEventType
assert hasattr(StreamEventType, 'TOOL_CALL_RESULT')
assert StreamEventType.TOOL_CALL_RESULT.value == "tool_call_result"
def test_existing_events_unchanged(self):
"""Verify existing event types are unchanged for backward compat."""
from praisonaiagents.streaming.events import StreamEventType
assert StreamEventType.DELTA_TOOL_CALL.value == "delta_tool_call"
assert StreamEventType.TOOL_CALL_END.value == "tool_call_end"
assert StreamEventType.REQUEST_START.value == "request_start"
assert StreamEventType.STREAM_END.value == "stream_end"
class TestStreamEventCreation:
"""Test creating StreamEvent with new types."""
def test_create_tool_call_start_event(self):
"""Verify TOOL_CALL_START event can be created with parsed args dict."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={
"name": "search_web",
"arguments": {"query": "Django tutorials"}, # Parsed dict, NOT JSON string
"id": "call_abc123",
},
agent_id="test_agent",
)
assert event.type == StreamEventType.TOOL_CALL_START
assert event.tool_call["name"] == "search_web"
assert isinstance(event.tool_call["arguments"], dict)
assert event.tool_call["arguments"]["query"] == "Django tutorials"
assert event.agent_id == "test_agent"
def test_create_tool_call_result_event(self):
"""Verify TOOL_CALL_RESULT event can be created with result."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
tool_call={
"name": "search_web",
"arguments": {"query": "Django tutorials"},
"result": "Found 10 results for Django tutorials",
"id": "call_abc123",
},
agent_id="test_agent",
metadata={"duration_ms": 150.5},
)
assert event.type == StreamEventType.TOOL_CALL_RESULT
assert event.tool_call["result"] == "Found 10 results for Django tutorials"
assert event.metadata["duration_ms"] == 150.5
class TestStreamEventEmitterWithNewEvents:
"""Test StreamEventEmitter emits new event types correctly."""
def test_emitter_emits_tool_call_start(self):
"""Verify emitter can emit TOOL_CALL_START events."""
from praisonaiagents.streaming.events import (
StreamEventEmitter, StreamEvent, StreamEventType
)
emitter = StreamEventEmitter()
received_events = []
def callback(event):
received_events.append(event)
emitter.add_callback(callback)
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={
"name": "read_file",
"arguments": {"filepath": "/tmp/test.txt"},
"id": "call_xyz789",
},
)
emitter.emit(event)
assert len(received_events) == 1
assert received_events[0].type == StreamEventType.TOOL_CALL_START
assert received_events[0].tool_call["arguments"]["filepath"] == "/tmp/test.txt"
def test_emitter_emits_tool_call_result(self):
"""Verify emitter can emit TOOL_CALL_RESULT events."""
from praisonaiagents.streaming.events import (
StreamEventEmitter, StreamEvent, StreamEventType
)
emitter = StreamEventEmitter()
received_events = []
def callback(event):
received_events.append(event)
emitter.add_callback(callback)
event = StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
tool_call={
"name": "read_file",
"arguments": {"filepath": "/tmp/test.txt"},
"result": "File contents here...",
"id": "call_xyz789",
},
)
emitter.emit(event)
assert len(received_events) == 1
assert received_events[0].type == StreamEventType.TOOL_CALL_RESULT
assert received_events[0].tool_call["result"] == "File contents here..."
def test_no_callbacks_no_overhead(self):
"""Verify no overhead when no callbacks registered."""
from praisonaiagents.streaming.events import (
StreamEventEmitter, StreamEvent, StreamEventType
)
emitter = StreamEventEmitter()
assert not emitter.has_callbacks
# Should not raise, just no-op
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={"name": "test", "arguments": {}},
)
emitter.emit(event) # No error, no callbacks called
class TestToolCallEventSemantics:
"""Test the semantic differences between tool call event types."""
def test_delta_vs_start_event_args_type(self):
"""
DELTA_TOOL_CALL has partial JSON string args.
TOOL_CALL_START has complete parsed dict args.
"""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
# DELTA_TOOL_CALL - partial JSON string (as received from LLM streaming)
delta_event = StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
tool_call={
"name": "search_web",
"arguments": '{"qu', # Partial JSON string
"id": "call_123",
},
)
# TOOL_CALL_START - complete parsed dict (at execution time)
start_event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={
"name": "search_web",
"arguments": {"query": "Django"}, # Complete parsed dict
"id": "call_123",
},
)
# Delta has string args
assert isinstance(delta_event.tool_call["arguments"], str)
# Start has dict args
assert isinstance(start_event.tool_call["arguments"], dict)
assert "query" in start_event.tool_call["arguments"]
class TestToolCallIdPropagation:
"""Test that tool_call_id is properly threaded through execute_tool."""
def test_execute_tool_accepts_tool_call_id(self):
"""Verify execute_tool() accepts tool_call_id parameter."""
from praisonaiagents import Agent
import inspect
sig = inspect.signature(Agent.execute_tool)
params = list(sig.parameters.keys())
assert 'tool_call_id' in params, "execute_tool should accept tool_call_id parameter"
def test_execute_tool_with_context_accepts_tool_call_id(self):
"""Verify _execute_tool_with_context() accepts tool_call_id parameter."""
from praisonaiagents import Agent
import inspect
sig = inspect.signature(Agent._execute_tool_with_context)
params = list(sig.parameters.keys())
assert 'tool_call_id' in params, "_execute_tool_with_context should accept tool_call_id parameter"
def test_tool_call_id_in_stream_event(self):
"""Verify tool_call_id can be included in StreamEvent."""
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={
"name": "test_tool",
"arguments": {"arg": "value"},
"id": "call_abc123xyz", # The tool_call_id
},
)
assert event.tool_call["id"] == "call_abc123xyz"
@@ -153,8 +153,12 @@ class TestDoomLoopRecovery:
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_doom_loop_eventually_aborts(self, mock_stage, mock_chat):
"""After exhausting recovery attempts, should abort."""
mock_chat.return_value = "Still processing the same thing"
agent = self._make_agent(threshold=2)
def side_effect(prompt):
# Simulate tool calls so no-tool-call termination doesn't fire
agent._autonomy_turn_tool_count = 1
return "Still processing the same thing"
mock_chat.side_effect = side_effect
result = agent.run_autonomous("Do something")
assert result.success is False
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
@@ -168,7 +172,6 @@ class TestDoomLoopRecovery:
def test_doom_loop_recovery_emits_observability(self, mock_stage, mock_chat):
"""Recovery actions should emit observability events when observe=True."""
from praisonaiagents import Agent
mock_chat.return_value = "Still processing the same thing"
agent = Agent(
instructions="Test agent",
autonomy={
@@ -178,6 +181,11 @@ class TestDoomLoopRecovery:
"observe": True,
},
)
def side_effect(prompt):
# Simulate tool calls so no-tool-call termination doesn't fire
agent._autonomy_turn_tool_count = 1
return "Still processing the same thing"
mock_chat.side_effect = side_effect
result = agent.run_autonomous("Do something")
obs = getattr(agent, '_observability_hooks', None)
assert obs is not None
@@ -545,3 +553,135 @@ class TestCLIBridgeApproval:
mgr = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
config = mgr.get_autonomy_config()
assert config["level"] == "full_auto"
# ============================================================
# Content Streaming Loop Detection
# ============================================================
class TestContentStreamingDetection:
"""Tests for content streaming loop detection in DoomLoopDetector."""
def test_repeated_chunks_detected(self):
"""Repeated identical response text should trigger content loop detection."""
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
detector = DoomLoopDetector(DoomLoopConfig(
max_repeated_chunks=5,
max_identical_actions=100, # disable action-based detection
max_similar_actions=100,
max_consecutive_failures=100,
max_no_progress_steps=100,
))
detector.start_session()
# Feed identical responses
repeated_text = "The quick brown fox jumps over the lazy dog. " * 5
for _ in range(10):
detector.record_response(repeated_text)
assert detector.is_doom_loop() is True
assert detector.get_loop_type().value == "repeated_output"
def test_varied_content_not_flagged(self):
"""Diverse response text should NOT trigger content loop detection."""
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
detector = DoomLoopDetector(DoomLoopConfig(
max_repeated_chunks=5,
max_identical_actions=100,
max_similar_actions=100,
max_consecutive_failures=100,
max_no_progress_steps=100,
))
detector.start_session()
# Feed unique responses
for i in range(10):
detector.record_response(f"Unique response number {i} with different content every time: {i * 7}")
assert detector._check_content_loop() is False
def test_content_loop_resets_on_start_session(self):
"""start_session() should reset content tracking."""
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
detector = DoomLoopDetector(DoomLoopConfig(max_repeated_chunks=3))
detector.start_session()
repeated = "Same text repeated over and over again here. " * 5
for _ in range(10):
detector.record_response(repeated)
assert detector._check_content_loop() is True
# Reset
detector.start_session()
assert detector._check_content_loop() is False
def test_tracker_record_response_delegates(self):
"""DoomLoopTracker.record_response() should delegate to DoomLoopDetector."""
from praisonaiagents.agent.autonomy import DoomLoopTracker
tracker = DoomLoopTracker(threshold=3)
with patch.object(tracker._delegate, 'record_response') as mock_rec:
tracker.record_response("some response text")
mock_rec.assert_called_once_with("some response text")
# ============================================================
# No-Tool-Call Termination Signal
# ============================================================
class TestNoToolCallTermination:
"""Tests for no-tool-call termination in run_autonomous()."""
def _make_agent(self, **autonomy_overrides):
from praisonaiagents import Agent
config = {"max_iterations": 10, "auto_escalate": False}
config.update(autonomy_overrides)
return Agent(instructions="Test agent", autonomy=config)
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_no_tool_calls_completes_after_consecutive_turns(self, mock_stage, mock_chat):
"""When model makes no tool calls for 2+ consecutive turns, should complete."""
# Use varied responses to avoid triggering doom loop detection.
# Each response is unique so doom loop fingerprints differ.
call_count = [0]
def side_effect(prompt):
call_count[0] += 1
return f"Analysis iteration {call_count[0]}: examining the data from angle {call_count[0]}."
mock_chat.side_effect = side_effect
agent = self._make_agent()
# Don't set any tool count — simulates no tools being called
result = agent.run_autonomous("Analyze something")
assert result.success is True
assert result.completion_reason == "no_tool_calls"
# Should take at least 2 iterations (first is allowed, second confirms)
assert result.iterations >= 2
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_tool_calls_prevent_premature_completion(self, mock_stage, mock_chat):
"""When tools are called, should NOT trigger no_tool_calls completion."""
call_count = [0]
def side_effect(prompt):
call_count[0] += 1
# Simulate agent incrementing tool count during chat
agent._autonomy_turn_tool_count = 1 # Tool was called
if call_count[0] >= 4:
return "Task completed."
return "I ran a tool and got results, processing further..."
mock_chat.side_effect = side_effect
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
# Should complete via keyword, not no_tool_calls
assert result.completion_reason == "goal"
@patch("praisonaiagents.agent.agent.Agent.chat")
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
def test_first_iteration_no_tools_does_not_terminate(self, mock_stage, mock_chat):
"""First iteration without tools should NOT terminate — model might be planning."""
call_count = [0]
def side_effect(prompt):
call_count[0] += 1
if call_count[0] == 1:
return "Let me plan my approach to this problem."
return "Task completed."
mock_chat.side_effect = side_effect
agent = self._make_agent()
result = agent.run_autonomous("Do something")
assert result.success is True
# First iteration should not terminate, so we get at least 2
assert result.iterations >= 2
@@ -320,3 +320,62 @@ class TestEnableDisable:
assert get_editor_output() is editor
disable_editor_output()
class TestLlmContentCallback:
"""Test llm_content callback for intermediate narrative display."""
def setup_method(self):
"""Clean up callback state before each test."""
from praisonaiagents.main import sync_display_callbacks
from praisonaiagents.output.editor import disable_editor_output
sync_display_callbacks.pop('llm_content', None)
disable_editor_output()
def teardown_method(self):
"""Restore state after each test."""
from praisonaiagents.main import sync_display_callbacks
from praisonaiagents.output.editor import disable_editor_output
sync_display_callbacks.pop('llm_content', None)
disable_editor_output()
def test_llm_content_in_supported_types(self):
"""llm_content must be a documented supported callback type."""
from praisonaiagents.main import SUPPORTED_CALLBACK_TYPES
assert 'llm_content' in SUPPORTED_CALLBACK_TYPES
def test_enable_editor_output_registers_llm_content(self):
"""enable_editor_output must register a llm_content callback."""
from praisonaiagents.output.editor import enable_editor_output
from praisonaiagents.main import sync_display_callbacks
enable_editor_output(use_color=False)
assert 'llm_content' in sync_display_callbacks
def test_llm_content_callback_fires_narrative(self):
"""Firing llm_content callback must create a NARRATIVE block."""
from praisonaiagents.output.editor import enable_editor_output, get_editor_output, BlockType
from praisonaiagents.main import execute_sync_callback
enable_editor_output(use_color=False)
execute_sync_callback('llm_content', content="Let me analyze this further.")
editor = get_editor_output()
blocks = editor.get_blocks()
narrative_blocks = [b for b in blocks if b.type == BlockType.NARRATIVE]
assert len(narrative_blocks) == 1
assert narrative_blocks[0].content == "Let me analyze this further."
def test_llm_content_callback_skips_empty(self):
"""Firing llm_content with empty content must not create a block."""
from praisonaiagents.output.editor import enable_editor_output, get_editor_output, BlockType
from praisonaiagents.main import execute_sync_callback
enable_editor_output(use_color=False)
execute_sync_callback('llm_content', content="")
execute_sync_callback('llm_content', content=" ")
editor = get_editor_output()
blocks = editor.get_blocks()
narrative_blocks = [b for b in blocks if b.type == BlockType.NARRATIVE]
assert len(narrative_blocks) == 0
@@ -0,0 +1,184 @@
"""
Unit tests for llm_content callback emission.
Verifies that the llm_content display event fires correctly for:
- Tool-call responses (intermediate narrative)
- Text-only responses (no tool calls)
- Empty response_text (no double-fire)
- Interaction callback already displayed (no duplicate)
"""
import pytest
from unittest.mock import patch, MagicMock, call
class TestLlmContentCallbackEmission:
"""Test that llm_content fires for all responses with text content."""
def _make_response(self, content="Hello", tool_calls=None):
"""Build a mock LiteLLM response."""
msg = {"content": content, "tool_calls": tool_calls}
return {"choices": [{"message": msg}]}
@patch('praisonaiagents.main.sync_display_callbacks', {})
def test_llm_content_fires_with_text_and_tool_calls(self):
"""llm_content callback should fire for text with tool calls."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output, BlockType
from praisonaiagents.main import sync_display_callbacks
try:
editor_output = enable_editor_output(use_color=False)
# The callback should be registered
assert 'llm_content' in sync_display_callbacks
# Simulate what llm.py now does: emit llm_content for text
callback = sync_display_callbacks['llm_content']
callback(content="Let me check the weather for you.", agent_name="WeatherBot")
# Verify narrative block was created
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
assert len(narrative_blocks) == 1
assert "weather" in narrative_blocks[0].content.lower()
finally:
disable_editor_output()
def test_narrative_method_renders_text(self):
"""EditorOutput.narrative() should add a NARRATIVE block."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.narrative("I'll search for the latest data.", agent_name="Researcher")
# Check that a NARRATIVE block was added
assert len(editor._blocks) == 1
assert editor._blocks[0].type == BlockType.NARRATIVE
def test_narrative_method_handles_empty(self):
"""EditorOutput.narrative() should not add block for empty content."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.narrative("", agent_name="Test")
editor.narrative(" ", agent_name="Test")
# No blocks should be added for empty content
assert len(editor._blocks) == 0
def test_llm_content_callback_registration(self):
"""enable_editor_output should register llm_content callback."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
from praisonaiagents.main import sync_display_callbacks
try:
enable_editor_output(use_color=False)
assert 'llm_content' in sync_display_callbacks
callback = sync_display_callbacks['llm_content']
assert callable(callback)
finally:
disable_editor_output()
def test_llm_content_callback_calls_narrative(self):
"""The llm_content callback should call EditorOutput.narrative()."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
from praisonaiagents.main import sync_display_callbacks
try:
editor_output = enable_editor_output(use_color=False)
callback = sync_display_callbacks['llm_content']
# Call the callback with content
callback(content="Analyzing the code structure", agent_name="CodeAgent")
# Check a NARRATIVE block was added
from praisonaiagents.output.editor import BlockType
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
assert len(narrative_blocks) == 1
finally:
disable_editor_output()
def test_llm_content_callback_ignores_empty(self):
"""The llm_content callback should ignore empty content."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
from praisonaiagents.main import sync_display_callbacks
try:
editor_output = enable_editor_output(use_color=False)
callback = sync_display_callbacks['llm_content']
# Call with empty content
callback(content="", agent_name="Test")
callback(content=None, agent_name="Test")
callback(content=" ", agent_name="Test")
# No blocks added
assert len(editor_output._blocks) == 0
finally:
disable_editor_output()
def test_narrative_before_tool_calls(self):
"""Narrative should appear before tool call blocks in the block list."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
# Simulate: LLM narrative first, then tool call
editor.narrative("Let me search for that information.")
editor.tool_call("internet_search", args={"query": "python 3.13"}, result="Found 5 results")
assert len(editor._blocks) == 2
assert editor._blocks[0].type == BlockType.NARRATIVE
assert editor._blocks[1].type == BlockType.COMMAND
def test_multiple_narrative_tool_groups(self):
"""Multiple narrative+tool groups should render in order."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
# First group
editor.narrative("I'll start by searching.")
editor.tool_call("internet_search", args={"query": "topic A"}, result="3 results")
# Second group
editor.narrative("Now let me create the summary file.")
editor.tool_call("write_file", args={"path": "/tmp/summary.md"}, result="File created")
assert len(editor._blocks) == 4
types = [b.type for b in editor._blocks]
assert types == [BlockType.NARRATIVE, BlockType.COMMAND, BlockType.NARRATIVE, BlockType.COMMAND]
class TestLlmContentNoDoubleEmission:
"""Test that llm_content doesn't double-emit when interaction fires."""
def test_interaction_callback_does_not_trigger_narrative(self):
"""After interaction callback fires, no narrative should duplicate."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
from praisonaiagents.main import sync_display_callbacks
try:
editor_output = enable_editor_output(use_color=False)
# Simulate: interaction fires first (final response)
interaction_cb = sync_display_callbacks.get('interaction')
if interaction_cb:
interaction_cb(
message="What is Python?",
response="Python is a programming language.",
agent_name="TestAgent",
)
# Then llm_content fires with same text (should NOT happen in prod
# due to callback_executed guard, but test the editor handles it)
llm_content_cb = sync_display_callbacks['llm_content']
llm_content_cb(content="Python is a programming language.", agent_name="TestAgent")
from praisonaiagents.output.editor import BlockType
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
summary_blocks = [b for b in editor_output._blocks if b.type == BlockType.SUMMARY]
# Both should exist since the guard is in llm.py, not editor.py
assert len(narrative_blocks) >= 1 or len(summary_blocks) >= 1
finally:
disable_editor_output()
@@ -410,3 +410,93 @@ class TestToolMappings:
assert callable(schedule_list)
schedule_remove = getattr(tools, 'schedule_remove')
assert callable(schedule_remove)
# ─── 9. ScheduleLoop Tests ───────────────────────────────────────────────────
class TestScheduleLoop:
"""Test ScheduleLoop tick loop."""
def test_schedule_loop_fires_due_jobs(self, tmp_path):
"""ScheduleLoop calls on_trigger for each due job."""
from praisonaiagents.scheduler.loop import ScheduleLoop
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
import threading
store = FileScheduleStore(store_dir=str(tmp_path))
job = ScheduleJob(
name="fire-me",
schedule=Schedule(kind="every", every_seconds=1),
message="hello",
)
job.last_run_at = time.time() - 10 # due
store.add(job)
triggered = []
event = threading.Event()
def on_trigger(j):
triggered.append(j.name)
event.set()
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
loop.start()
assert loop.is_running
event.wait(timeout=3)
loop.stop()
assert "fire-me" in triggered
def test_schedule_loop_updates_last_run(self, tmp_path):
"""After triggering, last_run_at is updated so job doesn't re-fire."""
from praisonaiagents.scheduler.loop import ScheduleLoop
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
import threading
store = FileScheduleStore(store_dir=str(tmp_path))
old_time = time.time() - 100
job = ScheduleJob(
name="update-me",
schedule=Schedule(kind="every", every_seconds=1),
message="test",
)
job.last_run_at = old_time
store.add(job)
event = threading.Event()
def on_trigger(j):
event.set()
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
loop.start()
event.wait(timeout=3)
loop.stop()
updated = store.get(job.id)
assert updated.last_run_at > old_time
def test_schedule_loop_start_stop(self, tmp_path):
"""ScheduleLoop starts and stops cleanly."""
from praisonaiagents.scheduler.loop import ScheduleLoop
from praisonaiagents.scheduler.store import FileScheduleStore
store = FileScheduleStore(store_dir=str(tmp_path))
loop = ScheduleLoop(on_trigger=lambda j: None, store=store, tick_seconds=0.1)
assert not loop.is_running
loop.start()
assert loop.is_running
# start() when already running is a no-op
loop.start()
assert loop.is_running
loop.stop()
assert not loop.is_running
def test_schedule_loop_lazy_import(self):
"""ScheduleLoop can be imported from scheduler package."""
from praisonaiagents.scheduler import ScheduleLoop
assert ScheduleLoop is not None
@@ -0,0 +1,198 @@
"""
TDD tests for store_learning and search_learning tools.
Tests follow the same pattern as test_memory_tools.py.
"""
import pytest
from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
# ─── Import Tests ────────────────────────────────────────────────────────────
class TestLearningToolImports:
"""Verify clean imports and no naming collisions."""
def test_import_from_tools_module(self):
from praisonaiagents.tools import store_learning, search_learning
assert callable(store_learning)
assert callable(search_learning)
def test_import_directly(self):
from praisonaiagents.tools.learning import store_learning, search_learning
assert callable(store_learning)
assert callable(search_learning)
def test_no_collision_with_learn_package(self):
"""tools.learning and memory.learn are separate Python paths."""
from praisonaiagents.tools.learning import store_learning
from praisonaiagents.memory.learn.manager import LearnManager
assert store_learning is not LearnManager
# ─── store_learning Tests ────────────────────────────────────────────────────
class TestStoreLearning:
"""Test store_learning tool function."""
def _make_state(self, learn_manager=None):
from praisonaiagents.tools.injected import AgentState
return AgentState(
agent_id="test",
run_id="run-1",
session_id="sess-1",
learn_manager=learn_manager,
)
def test_store_persona(self):
from praisonaiagents.tools.learning import store_learning
mgr = MagicMock()
mgr.capture_persona.return_value = MagicMock(id="e1", content="likes blue")
state = self._make_state(learn_manager=mgr)
result = store_learning("User likes blue", category="persona", state=state)
mgr.capture_persona.assert_called_once_with("User likes blue")
assert "persona" in result.lower()
def test_store_insight(self):
from praisonaiagents.tools.learning import store_learning
mgr = MagicMock()
mgr.capture_insight.return_value = MagicMock(id="e2", content="uses FastAPI")
state = self._make_state(learn_manager=mgr)
result = store_learning("Project uses FastAPI", category="insights", state=state)
mgr.capture_insight.assert_called_once_with("Project uses FastAPI")
assert "insight" in result.lower()
def test_store_pattern(self):
from praisonaiagents.tools.learning import store_learning
mgr = MagicMock()
mgr.capture_pattern.return_value = MagicMock(id="e3", content="deploy=staging")
state = self._make_state(learn_manager=mgr)
result = store_learning("deploy means staging", category="patterns", state=state)
mgr.capture_pattern.assert_called_once_with("deploy means staging")
assert "pattern" in result.lower()
def test_store_decision(self):
from praisonaiagents.tools.learning import store_learning
mgr = MagicMock()
mgr.capture_decision.return_value = MagicMock(id="e4", content="chose postgres")
state = self._make_state(learn_manager=mgr)
result = store_learning("Use PostgreSQL", category="decisions", state=state)
mgr.capture_decision.assert_called_once_with("Use PostgreSQL")
assert "decision" in result.lower()
def test_store_default_category_is_persona(self):
"""Default category should be persona (most common use case)."""
from praisonaiagents.tools.learning import store_learning
mgr = MagicMock()
mgr.capture_persona.return_value = MagicMock(id="e5")
state = self._make_state(learn_manager=mgr)
store_learning("Prefers Python", state=state)
mgr.capture_persona.assert_called_once()
def test_store_no_learn_manager(self):
"""Graceful response when learn is not configured."""
from praisonaiagents.tools.learning import store_learning
state = self._make_state(learn_manager=None)
result = store_learning("test", state=state)
assert "not configured" in result.lower() or "enable" in result.lower()
def test_store_no_state(self):
"""Graceful response when no state is available."""
from praisonaiagents.tools.learning import store_learning
with patch("praisonaiagents.tools.learning.get_current_state", return_value=None):
result = store_learning("test")
assert "not configured" in result.lower() or "enable" in result.lower()
# ─── search_learning Tests ───────────────────────────────────────────────────
class TestSearchLearning:
"""Test search_learning tool function."""
def _make_state(self, learn_manager=None):
from praisonaiagents.tools.injected import AgentState
return AgentState(
agent_id="test",
run_id="run-1",
session_id="sess-1",
learn_manager=learn_manager,
)
def test_search_with_results(self):
from praisonaiagents.tools.learning import search_learning
mgr = MagicMock()
mgr.search.return_value = {
"persona": [{"id": "1", "content": "likes blue"}],
"insights": [{"id": "2", "content": "uses FastAPI"}],
}
state = self._make_state(learn_manager=mgr)
result = search_learning("preferences", state=state)
mgr.search.assert_called_once_with("preferences", limit=5)
assert "likes blue" in result
assert "uses FastAPI" in result
def test_search_specific_category(self):
from praisonaiagents.tools.learning import search_learning
mgr = MagicMock()
# When category is specified, search should filter to that store
mgr.search.return_value = {
"persona": [{"id": "1", "content": "prefers bullet points"}],
}
state = self._make_state(learn_manager=mgr)
result = search_learning("style", category="persona", state=state)
assert "prefers bullet points" in result
def test_search_no_results(self):
from praisonaiagents.tools.learning import search_learning
mgr = MagicMock()
mgr.search.return_value = {}
state = self._make_state(learn_manager=mgr)
result = search_learning("nonexistent", state=state)
assert "no" in result.lower() and ("found" in result.lower() or "learning" in result.lower())
def test_search_no_learn_manager(self):
from praisonaiagents.tools.learning import search_learning
state = self._make_state(learn_manager=None)
result = search_learning("test", state=state)
assert "not configured" in result.lower() or "enable" in result.lower()
# ─── Schema Tests ────────────────────────────────────────────────────────────
class TestLearningToolSchema:
"""Verify Injected[AgentState] is hidden from LLM schema."""
def test_store_learning_injected_hidden(self):
import inspect
from praisonaiagents.tools.learning import store_learning
sig = inspect.signature(store_learning)
params = list(sig.parameters.keys())
# 'state' should exist for injection but be hidden from LLM
assert "state" in params
# LLM-visible params should be content + category
assert "content" in params
assert "category" in params
def test_search_learning_injected_hidden(self):
import inspect
from praisonaiagents.tools.learning import search_learning
sig = inspect.signature(search_learning)
params = list(sig.parameters.keys())
assert "state" in params
assert "query" in params
assert "limit" in params
"""
TDD tests for store_learning and search_learning tools.
"""
@@ -0,0 +1,211 @@
"""
Tests for memory tools (store_memory, search_memory).
These are standard tool functions that agents use via tools=[store_memory, search_memory].
They use Injected[AgentState] to access the memory instance at runtime.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestMemoryToolImports:
"""Test that memory tools can be imported."""
def test_import_from_tools_module(self):
"""store_memory and search_memory importable from tools package."""
from praisonaiagents.tools import store_memory, search_memory
assert callable(store_memory)
assert callable(search_memory)
def test_import_directly(self):
"""Direct import from tools.memory module."""
from praisonaiagents.tools.memory import store_memory, search_memory
assert callable(store_memory)
assert callable(search_memory)
def test_no_collision_with_memory_package(self):
"""tools.memory does not collide with memory/ package."""
import praisonaiagents.memory as mem_pkg
import praisonaiagents.tools.memory as tools_mem
assert mem_pkg is not tools_mem
class TestStoreMemory:
"""Test store_memory tool function."""
def test_store_memory_with_memory_instance(self):
"""store_memory stores content via the injected memory."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
mock_memory = MagicMock()
mock_memory.store_short_term = MagicMock()
mock_memory.store_long_term = MagicMock()
state = AgentState(
agent_id="test-agent",
run_id="test-run",
session_id="test-session",
memory=mock_memory,
)
with with_injection_context(state):
result = store_memory(content="User likes Python", memory_type="long_term")
assert "stored" in result.lower() or "success" in result.lower() or "saved" in result.lower()
def test_store_memory_short_term(self):
"""store_memory correctly stores to short_term."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
mock_memory = MagicMock()
mock_memory.store_short_term = MagicMock()
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
)
with with_injection_context(state):
store_memory(content="temporary note", memory_type="short_term")
mock_memory.store_short_term.assert_called_once()
def test_store_memory_long_term(self):
"""store_memory correctly stores to long_term."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
mock_memory = MagicMock()
mock_memory.store_long_term = MagicMock()
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
)
with with_injection_context(state):
store_memory(content="important fact", memory_type="long_term")
mock_memory.store_long_term.assert_called_once()
def test_store_memory_no_memory_configured(self):
"""store_memory returns helpful message when no memory."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=None,
)
with with_injection_context(state):
result = store_memory(content="test")
assert "not configured" in result.lower() or "not available" in result.lower()
def test_store_memory_no_state(self):
"""store_memory returns helpful message with no injection context."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import set_current_state
set_current_state(None)
result = store_memory(content="test")
assert "not configured" in result.lower() or "not available" in result.lower()
class TestSearchMemory:
"""Test search_memory tool function."""
def test_search_memory_with_results(self):
"""search_memory returns formatted results."""
from praisonaiagents.tools.memory import search_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
mock_memory = MagicMock()
mock_memory.search_long_term = MagicMock(return_value=[
{"text": "User likes Python", "score": 0.9},
{"text": "User is a developer", "score": 0.7},
])
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
)
with with_injection_context(state):
result = search_memory(query="programming preferences")
assert "Python" in result
def test_search_memory_no_results(self):
"""search_memory returns clear message when nothing found."""
from praisonaiagents.tools.memory import search_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
mock_memory = MagicMock()
mock_memory.search_long_term = MagicMock(return_value=[])
mock_memory.search_short_term = MagicMock(return_value=[])
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
)
with with_injection_context(state):
result = search_memory(query="nonexistent topic")
assert "no" in result.lower() or "nothing" in result.lower() or "found" in result.lower()
def test_search_memory_no_memory_configured(self):
"""search_memory returns helpful message when no memory."""
from praisonaiagents.tools.memory import search_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=None,
)
with with_injection_context(state):
result = search_memory(query="test")
assert "not configured" in result.lower() or "not available" in result.lower()
class TestMemoryToolSchema:
"""Test that Injected params are hidden from LLM schema."""
def test_store_memory_injected_hidden(self):
"""state param should not appear in tool schema."""
from praisonaiagents.tools.memory import store_memory
from praisonaiagents.tools.injected import get_injected_params
injected = get_injected_params(store_memory)
assert "state" in injected, "state param must be Injected"
def test_search_memory_injected_hidden(self):
"""state param should not appear in tool schema."""
from praisonaiagents.tools.memory import search_memory
from praisonaiagents.tools.injected import get_injected_params
injected = get_injected_params(search_memory)
assert "state" in injected, "state param must be Injected"
class TestMemoryToolsWithFileMemory:
"""Integration test with real FileMemory (no mocks)."""
def test_store_and_search_roundtrip(self, tmp_path):
"""Store a memory, then search for it."""
from praisonaiagents.tools.memory import store_memory, search_memory
from praisonaiagents.tools.injected import AgentState, with_injection_context
from praisonaiagents.memory.file_memory import FileMemory
fm = FileMemory(user_id="test_mem_tools", base_path=str(tmp_path))
state = AgentState(
agent_id="a", run_id="r", session_id="s", memory=fm,
)
with with_injection_context(state):
store_result = store_memory(content="The capital of France is Paris", memory_type="long_term")
assert "stored" in store_result.lower() or "saved" in store_result.lower()
search_result = search_memory(query="capital France")
assert "Paris" in search_result
+60 -2
View File
@@ -602,6 +602,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/9d/37e5da7519de7b0b070a3fedd4230fe76d50d2a21403e0f2153d70ac4163/cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", size = 3128774, upload-time = "2024-11-27T18:07:02.157Z" },
]
[[package]]
name = "ddgs"
version = "9.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "lxml" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/9e/d89f0c24d78812bad0b4150d9a432925aa756b4bfeb4ef4815fe6ff8f2a6/ddgs-9.11.3.tar.gz", hash = "sha256:6098c030d6806217260071d85e38d9b94b99fe326a3c40ebf5de25f620528ae2", size = 34776, upload-time = "2026-03-11T07:12:02.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/9d/018d745128a9a33aff3e6b8f0260f7b970784d4b31573d36ee233b2e4db1/ddgs-9.11.3-py3-none-any.whl", hash = "sha256:596d656d00219b4748d839de1fa9a9c3eb5dd36db07365331f7526201115f18a", size = 43691, upload-time = "2026-03-11T07:12:00.21Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
@@ -2564,7 +2578,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.38"
version = "1.5.73"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -2578,6 +2592,7 @@ dependencies = [
all = [
{ name = "chonkie" },
{ name = "chromadb" },
{ name = "ddgs" },
{ name = "fastapi" },
{ name = "litellm" },
{ name = "markitdown", extra = ["all"] },
@@ -2640,6 +2655,9 @@ os = [
{ name = "pyjwt" },
{ name = "uvicorn" },
]
search = [
{ name = "ddgs" },
]
telemetry = [
{ name = "posthog" },
]
@@ -2652,6 +2670,7 @@ requires-dist = [
{ name = "chromadb", marker = "extra == 'graph'", specifier = ">=1.0.0" },
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
@@ -2674,6 +2693,7 @@ requires-dist = [
{ name = "praisonaiagents", extras = ["mcp"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic", marker = "extra == 'llm'", specifier = ">=2.10.0" },
@@ -2688,7 +2708,45 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "all"]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "all"]
[[package]]
name = "primp"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/0e/62ed44af95c66fd6fa8ad49c8bde815f64c7e976772d6979730be2b7cd97/primp-1.1.3.tar.gz", hash = "sha256:56adc3b8a5048cbd5f926b21fdff839195f3a9181512ca33f56ddc66f4c95897", size = 311356, upload-time = "2026-03-11T06:42:51.763Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/6b/36794b5758a0dd1251e67b6ab3ea946e53fa69745e0ecc29facc072ddf5b/primp-1.1.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24383cfc267f620769be102b7fa4b64c7d47105f86bd21d047f1e07709e83c6e", size = 4000660, upload-time = "2026-03-11T06:42:58.092Z" },
{ url = "https://files.pythonhosted.org/packages/98/18/ebbe318a926d158c57f9e9cf49bbea70e8f0bd7f87e7675ed68e0d6ab433/primp-1.1.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:61bcb8c53b41e4bac43d04a1374b6ab7d8ded0f3517d32c5cdd5c30562756805", size = 3737318, upload-time = "2026-03-11T06:42:50.19Z" },
{ url = "https://files.pythonhosted.org/packages/a9/4c/430c9154284b53b771e6713a18dec4ad0159e4a501a20b222d67c730ced9/primp-1.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0c6b9388578ee9d903f30549a792c5f391fdeb9d36b508da2ffb8e13c764954", size = 3881005, upload-time = "2026-03-11T06:43:12.894Z" },
{ url = "https://files.pythonhosted.org/packages/93/34/2466ef66386a1b50e6aaf7832f9f603628407bb33342378faf4b38c4aee8/primp-1.1.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09a8bfa870c92c81d76611846ec53b2520845e3ec5f4139f47604986bcf4bc25", size = 3514480, upload-time = "2026-03-11T06:43:06.058Z" },
{ url = "https://files.pythonhosted.org/packages/ff/42/ca7a71df6493dd6c1971c0cc3b20b8125e2547eb3bf88b4429715cb6ed81/primp-1.1.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac372cb9959fff690b255fad91c5b3bc948c14065da9fc00ad80d139651515af", size = 3734658, upload-time = "2026-03-11T06:43:47.486Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7c/0fb34db619e9935e11140929713c2c7b5323c1e8ba75cad6f0aade51c89d/primp-1.1.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3034672a007f04e12b8fe7814c97ea172e8b9c5d45bd7b00cf6e7334fdd4222a", size = 4011898, upload-time = "2026-03-11T06:43:41.121Z" },
{ url = "https://files.pythonhosted.org/packages/da/8b/afd1bd8b14f38d58c5ebd0d45fc6b74914956907aa4e981bb2e5231626d3/primp-1.1.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a07d5b7d7278dc63452a59f3bf851dc4d1f8ddc2aada7844cbdb68002256e2f4", size = 3910728, upload-time = "2026-03-11T06:43:01.819Z" },
{ url = "https://files.pythonhosted.org/packages/32/9e/1ec3a9678efcbb51e50d7b4886d9195f956c9fd7f4efcff13ccb152248b0/primp-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08eec2f58abbcc1060032a2af81dabacec87a580a364a75862039f7422ac82e6", size = 4114189, upload-time = "2026-03-11T06:42:47.639Z" },
{ url = "https://files.pythonhosted.org/packages/28/d9/76de611027c0688be188d5a833be45b1e36d9c0c98baefab27bf6336ab9d/primp-1.1.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9716d4cd36db2c175443fe1bbd54045a944fc9c49d01a385af8ada1fe9c948df", size = 4061973, upload-time = "2026-03-11T06:43:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/37/3b/a30a5ea366705d0ece265b12ad089793d644bd5730b18201e3a0a7fa7b5f/primp-1.1.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e19daca65dc6df369c33e711fa481ad2afe5d26c5bde926c069b3ab067c4fd45", size = 3747920, upload-time = "2026-03-11T06:43:10.403Z" },
{ url = "https://files.pythonhosted.org/packages/df/46/e3c323221c371cdfe6c2ed971f7a70e3b69f30b561977715c55230bd5fda/primp-1.1.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ee357537712aa486364b0194cf403c5f9eaaa1354e23e9ac8322a22003f31e6b", size = 3861184, upload-time = "2026-03-11T06:43:49.391Z" },
{ url = "https://files.pythonhosted.org/packages/8a/7f/babaf00753daad7d80061003d7ae1bdfca64ea94c181cdea8d25c8a7226a/primp-1.1.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06c53e77ebf6ac00633bc09e7e5a6d1a994592729d399ca8f065451a2574b92e", size = 4364610, upload-time = "2026-03-11T06:42:56.223Z" },
{ url = "https://files.pythonhosted.org/packages/03/48/c7bca8045c681f5f60972c180d2a20582c7a0857b3b07b12e0a0ee062ac4/primp-1.1.3-cp310-abi3-win32.whl", hash = "sha256:4b1ea3693c118bf04a6e05286f0a73637cf6fe5c9fd77fa1e29a01f190adf512", size = 3265160, upload-time = "2026-03-11T06:43:43.774Z" },
{ url = "https://files.pythonhosted.org/packages/45/3e/4a4b8a0f6f15734cded91e85439e68912b2bb8eafe7132420c13c2db8340/primp-1.1.3-cp310-abi3-win_amd64.whl", hash = "sha256:5ea386a4c8c4d8c1021d17182f4ee24dbb6f17c107c4e9ee5500b6372cf08f32", size = 3603953, upload-time = "2026-03-11T06:43:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/70/46/1baf13a7f5fbed6052deb3e4822c69441a8d0fd990fe2a50e4cec802130b/primp-1.1.3-cp310-abi3-win_arm64.whl", hash = "sha256:63c7b1a1ccbcd07213f438375df186f807cdc5214bc2debb055737db9b5078de", size = 3619917, upload-time = "2026-03-11T06:42:44.76Z" },
{ url = "https://files.pythonhosted.org/packages/be/0c/a73cbe13f075e7ceaa5172b44ebc6f423713c6b4efe168114993a1710b26/primp-1.1.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4b3d52f3233134584ef527e7e52f1b371a964ade1df0461f8187100e41d7fa84", size = 3987141, upload-time = "2026-03-11T06:43:24.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/56/b70d7991fb1e07af53706b1f69f78a0b440a7b4b2a2999c44ab44afef1e7/primp-1.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b3d947e2c1d15147e8f4736d027b9f3bef518d67da859ead1c54e028ff491bbb", size = 3735665, upload-time = "2026-03-11T06:43:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/31/82/69efc663341c2bab55659ed221903a090e5c80255c2de2acc70f3726a3fc/primp-1.1.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ed2fee7d4758f6bb873b19a6759f54e0bc453213dad5ba7e52de7582921079", size = 3873695, upload-time = "2026-03-11T06:43:15.396Z" },
{ url = "https://files.pythonhosted.org/packages/07/7e/6b360742019ef8fb4ea036a420eb21b0a58d380ca09c68b075fc103cc043/primp-1.1.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5aa717f256af9e4391fb1c4dc946d99d04652b4c57dad20c3947e839ab26769", size = 3512644, upload-time = "2026-03-11T06:43:08.368Z" },
{ url = "https://files.pythonhosted.org/packages/03/46/51d2ada6d5b53b8496eddf2c80392deab13698987412d0234f88e72390c1/primp-1.1.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17f37fcacd97540f68b06f2b468b111ca7f2b142c48370db7344b522274fc0d6", size = 3733114, upload-time = "2026-03-11T06:43:22.838Z" },
{ url = "https://files.pythonhosted.org/packages/45/f5/5f5f5f4bef7e247ec3543e2fbdb670d8db8753a7693baf9c8b9fcf52cd43/primp-1.1.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5f010d0b8ba111dd9a66f814c2cd56332e047c98f45d7714ffbf2b1cec5b073", size = 4005664, upload-time = "2026-03-11T06:43:20.824Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bf/99cf4a5f179b3f13b0c2ba4d3ae8f8af19f0084308e76cb79a0cee03c31b/primp-1.1.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e1e431915e4a7094d589213fc14e955243d93751031d889f4b359fa8ed54298", size = 3895746, upload-time = "2026-03-11T06:43:35.376Z" },
{ url = "https://files.pythonhosted.org/packages/c3/75/4c625e1cab37585365b0856ca44f31ad598e92a847d23561f454b7f36fca/primp-1.1.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaffa22dae2f193d899d9f68cca109ea5d16cdf4c901c20cec186de89e7d5db4", size = 4109815, upload-time = "2026-03-11T06:43:04.059Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/6197ea78779d359f307be1acc64659896fc960ed91c0bdc6e6e698e423e6/primp-1.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f93bee50990884621ef482e8434e87f9fbb4eca6f4d47973c44c5d6393c35679", size = 4050839, upload-time = "2026-03-11T06:43:18.296Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b2/cdd565b28bcf7ce555f4decdf89dafd16db8ed3ba8661890d3b9337abe45/primp-1.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:399dfb9ad01c3612c9e510a7034ac925af5524cade0961d8a019dedd90a46474", size = 3748397, upload-time = "2026-03-11T06:43:27.347Z" },
{ url = "https://files.pythonhosted.org/packages/62/6e/def3a90821b52589dbe1f57477c2c89bde7a5b26a7c166d7751930c06f98/primp-1.1.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:78ce595bbb9f339e83975efa9db2a81128842fad1a2fdafb78d72fcdc59590fc", size = 3861261, upload-time = "2026-03-11T06:43:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/10/7d/3e610614d6a426502cfc6eccea21ef4557b39177d365df393c994945ca43/primp-1.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d709bdf520aa9401c0592b642730b3477c828629f01d2550977b77135b34e8d", size = 4358608, upload-time = "2026-03-11T06:43:45.606Z" },
{ url = "https://files.pythonhosted.org/packages/91/50/eb190cefe5eb05896825a5b3365d5650b9327161329cd1df4f7351b66ba9/primp-1.1.3-cp314-cp314t-win32.whl", hash = "sha256:6fe893eb87156dfb146dd666c7c8754670de82e38af0a27d82a47b7461ec2eea", size = 3259903, upload-time = "2026-03-11T06:42:59.922Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a8/9e8534bc6d729a667f79b249fcdbf2230b0eb41214e277998cd6be900498/primp-1.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ced76ef6669f31dc4af25e81e87914310645bcfc0892036bde084dafd6d00c3c", size = 3602569, upload-time = "2026-03-11T06:42:53.955Z" },
{ url = "https://files.pythonhosted.org/packages/9c/92/e18be996a01c7fd0e7dd7d198edefe42813cdfe1637bbbc80370ce656f62/primp-1.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:efadef0dfd10e733a254a949abf9ed05c668c28a68aa6513d811c0c6acd54cdb", size = 3611571, upload-time = "2026-03-11T06:43:31.249Z" },
]
[[package]]
name = "propcache"
+467 -3093
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.38.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.38.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.73.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.73.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+9 -1
View File
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
from .discord import DiscordBot
from .slack import SlackBot
from .whatsapp import WhatsAppBot
from .email import EmailBot
from .agentmail import AgentMailBot
from .bot import Bot
from .botos import BotOS
from ._slack_approval import SlackApproval
@@ -34,6 +36,12 @@ def __getattr__(name: str):
if name == "WhatsAppBot":
from .whatsapp import WhatsAppBot
return WhatsAppBot
if name == "EmailBot":
from .email import EmailBot
return EmailBot
if name == "AgentMailBot":
from .agentmail import AgentMailBot
return AgentMailBot
if name == "Bot":
from .bot import Bot
return Bot
@@ -58,7 +66,7 @@ def __getattr__(name: str):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "EmailBot", "AgentMailBot",
"Bot", "BotOS",
"SlackApproval", "TelegramApproval", "DiscordApproval",
"WebhookApproval", "HTTPApproval",
+13 -1
View File
@@ -24,10 +24,22 @@ class AgentConfigSchema(BaseModel):
class ChannelConfigSchema(BaseModel):
"""Schema for a single channel configuration."""
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
group_policy: str = "respond_all" # respond_all, mention_only, command_only
allowlist: List[str] = Field(default_factory=list)
blocklist: List[str] = Field(default_factory=list)
webhook_url: Optional[str] = None
webhook_port: int = 8080
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
allowed = {"poll", "ws", "webhook", "hybrid"}
if v not in allowed:
raise ValueError(
f"Invalid mode '{v}'. Must be one of: {', '.join(sorted(allowed))}"
)
return v
@field_validator("token")
@classmethod
@@ -91,7 +103,7 @@ class BotYamlSchema(BaseModel):
"(telegram, discord, slack, whatsapp) to your bot.yaml"
)
# Validate channel names
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
for name in self.channels:
if name not in valid_platforms:
raise ValueError(
@@ -0,0 +1,128 @@
"""
Shared email utilities for PraisonAI bots.
Contains common email logic used by both EmailBot (IMAP/SMTP) and AgentMailBot (API).
"""
from __future__ import annotations
from typing import Dict, Set
# Headers that indicate auto-generated/auto-reply emails
AUTO_REPLY_HEADERS: Set[str] = {
"auto-submitted",
"x-auto-response-suppress",
"x-autoreply",
"x-autorespond",
"x-autogenerated",
"precedence", # Often set to "bulk" or "auto_reply"
}
# Sender patterns to never reply to (prevents loops)
BLOCKED_SENDERS: Set[str] = {
"mailer-daemon",
"postmaster",
"noreply",
"no-reply",
"donotreply",
"do-not-reply",
"bounce",
"auto",
"daemon",
}
def is_auto_reply(headers: Dict[str, str]) -> bool:
"""Check if email headers indicate an auto-reply.
Args:
headers: Dictionary of email headers (case-insensitive keys recommended).
Returns:
True if the email appears to be an auto-reply.
"""
headers_lower = {k.lower(): v for k, v in headers.items()}
for header in AUTO_REPLY_HEADERS:
if header in headers_lower:
value = headers_lower[header]
# auto-submitted: auto-replied, auto-generated, auto-notified
if header == "auto-submitted" and value and value.lower() != "no":
return True
# precedence: bulk, junk, list, auto_reply
if header == "precedence" and value and value.lower() in ("bulk", "junk", "auto_reply"):
return True
# Other headers: any non-empty value indicates auto-reply
if header not in ("auto-submitted", "precedence") and value:
return True
return False
def is_blocked_sender(sender: str) -> bool:
"""Check if sender should be blocked (system addresses, no-reply, etc.).
Args:
sender: Email address or display name of sender.
Returns:
True if the sender should not receive replies.
"""
if not sender:
return True
sender_lower = sender.lower()
return any(blocked in sender_lower for blocked in BLOCKED_SENDERS)
def extract_email_address(full_address: str) -> str:
"""Extract email address from a full address string.
Args:
full_address: Full address like "John Doe <[email protected]>" or "[email protected]"
Returns:
Just the email address part.
"""
if not full_address:
return ""
# Handle "Name <email>" format
if "<" in full_address and ">" in full_address:
start = full_address.index("<") + 1
end = full_address.index(">")
return full_address[start:end].strip()
return full_address.strip()
def normalize_subject(subject: str, strip_prefixes: bool = True) -> str:
"""Normalize email subject for comparison/threading.
Args:
subject: Original subject line.
strip_prefixes: If True, removes Re:, Fwd:, etc.
Returns:
Normalized subject string.
"""
if not subject:
return ""
result = subject.strip()
if strip_prefixes:
# Remove common reply/forward prefixes
prefixes = ("re:", "fwd:", "fw:", "aw:", "sv:", "vs:")
while True:
lower = result.lower()
stripped = False
for prefix in prefixes:
if lower.startswith(prefix):
result = result[len(prefix):].strip()
stripped = True
break
if not stripped:
break
return result
@@ -15,6 +15,8 @@ _BUILTIN_PLATFORMS: Dict[str, tuple] = {
"discord": ("praisonai.bots.discord", "DiscordBot"),
"slack": ("praisonai.bots.slack", "SlackBot"),
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
"email": ("praisonai.bots.email", "EmailBot"),
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
}
# Custom platforms registered at runtime
+913
View File
@@ -0,0 +1,913 @@
"""
AgentMail Bot implementation for PraisonAI.
Provides an agent-native email bot using the AgentMail API for zero-config
email functionality. No IMAP/SMTP setup required.
Requires: pip install agentmail
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
BotUser,
BotChannel,
MessageType,
ProbeResult,
HealthResult,
)
from ._session import BotSessionManager
from ._email_utils import is_auto_reply, is_blocked_sender, extract_email_address
logger = logging.getLogger(__name__)
class AgentMailBot(ChatCommandMixin, MessageHookMixin):
"""Agent-native email bot using AgentMail API.
Zero-config email for agents — no IMAP/SMTP setup required.
Creates dedicated inboxes programmatically via AgentMail API.
Example:
from praisonai.bots import AgentMailBot
from praisonaiagents import Agent
agent = Agent(name="email_assistant")
bot = AgentMailBot(
token="am_...", # AGENTMAIL_API_KEY
agent=agent,
)
await bot.start()
print(bot.email_address) # [email protected]
Environment variables (if not passed directly):
AGENTMAIL_API_KEY: API key for AgentMail service
AGENTMAIL_INBOX_ID: Optional existing inbox ID to reuse
AGENTMAIL_DOMAIN: Optional custom domain for inbox creation
"""
def __init__(
self,
token: str,
agent: Optional["Agent"] = None,
config: Optional[BotConfig] = None,
inbox_id: Optional[str] = None,
domain: Optional[str] = None,
polling_interval: int = 10,
mode: Optional[str] = None,
webhook_port: Optional[int] = None,
**kwargs,
):
"""Initialize AgentMailBot.
Args:
token: AgentMail API key (or set AGENTMAIL_API_KEY env var)
agent: PraisonAI agent to handle messages
config: Bot configuration
inbox_id: Existing inbox ID to reuse (optional)
domain: Custom domain for new inbox (optional)
polling_interval: Seconds between polling for new messages
mode: Event mode — poll (default), ws, webhook, or hybrid
webhook_port: Port for webhook server (default: 8080)
**kwargs: Additional configuration
"""
self._token = token or os.getenv("AGENTMAIL_API_KEY", "")
self._agent = agent
self._inbox_id = inbox_id or os.getenv("AGENTMAIL_INBOX_ID")
self._domain = domain or os.getenv("AGENTMAIL_DOMAIN")
self._polling_interval = polling_interval
# Bot state
self._is_running = False
self._started_at: Optional[float] = None
self._bot_user: Optional[BotUser] = None
self._email_address: Optional[str] = None
self._poll_task: Optional[asyncio.Task] = None
self._ws_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
# AgentMail clients (lazy loaded)
self._client: Any = None
self._async_client: Any = None
# Webhook server state
self._webhook_runner: Any = None
self._webhook_site: Any = None
# Session manager for conversation state
self.config = config or BotConfig()
self._session = BotSessionManager(self.config)
# Resolve mode: explicit param > config > default
if mode:
self._mode = mode
elif self.config.mode != "poll":
self._mode = self.config.mode
else:
self._mode = "poll"
# Resolve webhook port
self._webhook_port = webhook_port or self.config.webhook_port
# Command and message handlers
self._command_handlers: Dict[str, Callable] = {}
self._message_handlers: List[Callable] = []
# Track processed message IDs to avoid duplicates
self._processed_ids: set = set()
@property
def platform(self) -> str:
"""Platform identifier."""
return "agentmail"
@property
def is_running(self) -> bool:
"""Whether the bot is currently running."""
return self._is_running
@property
def bot_user(self) -> Optional[BotUser]:
"""The bot's user identity."""
return self._bot_user
@property
def email_address(self) -> Optional[str]:
"""The bot's email address."""
return self._email_address
def get_agent(self) -> Optional["Agent"]:
"""Get the associated agent."""
return self._agent
def set_agent(self, agent: "Agent") -> None:
"""Set the associated agent."""
self._agent = agent
def _get_client(self) -> Any:
"""Lazy load sync AgentMail client."""
if self._client is None:
try:
from agentmail import AgentMail
self._client = AgentMail(api_key=self._token)
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
return self._client
def _get_async_client(self) -> Any:
"""Lazy load async AgentMail client (for WS mode)."""
if self._async_client is None:
try:
from agentmail import AsyncAgentMail
self._async_client = AsyncAgentMail(api_key=self._token)
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
return self._async_client
async def start(self) -> None:
"""Start the AgentMail bot.
Creates or connects to an inbox and begins receiving messages
using the configured mode (poll, ws, webhook, or hybrid).
"""
if self._is_running:
logger.warning("AgentMailBot is already running")
return
if not self._token:
raise ValueError(
"AgentMail API key required. Set AGENTMAIL_API_KEY env var or pass token parameter."
)
client = self._get_client()
# Create or get inbox
if self._inbox_id:
# Use existing inbox — inbox_id IS the email address in AgentMail SDK
self._email_address = self._inbox_id
logger.info(f"Using existing AgentMail inbox: {self._email_address}")
else:
# Create new inbox
try:
inbox_params = {}
if self._domain:
inbox_params["domain"] = self._domain
inbox = client.inboxes.create(**inbox_params)
# SDK v0.4.7: inbox_id IS the email address (e.g. "[email protected]")
self._inbox_id = inbox.inbox_id
self._email_address = inbox.inbox_id
logger.info(f"Created AgentMail inbox: {self._email_address}")
except Exception as e:
raise ConnectionError(f"Failed to create inbox: {e}")
self._bot_user = BotUser(
user_id=self._inbox_id,
username=self._email_address,
display_name=self._email_address.split("@")[0] if self._email_address else "agent",
is_bot=True,
)
self._is_running = True
self._started_at = time.time()
self._stop_event.clear()
# Branch on mode
if self._mode == "ws":
self._ws_task = asyncio.create_task(self._ws_loop())
logger.info(f"AgentMailBot started (ws): {self._email_address}")
elif self._mode == "webhook":
await self._start_webhook_mode()
logger.info(f"AgentMailBot started (webhook): {self._email_address}")
elif self._mode == "hybrid":
self._ws_task = asyncio.create_task(self._ws_loop())
self._poll_task = asyncio.create_task(
self._poll_loop(interval_override=60)
)
logger.info(f"AgentMailBot started (hybrid): {self._email_address}")
else:
# Default: poll mode (existing behaviour — zero breaking change)
self._poll_task = asyncio.create_task(self._poll_loop())
logger.info(f"AgentMailBot started (poll): {self._email_address}")
async def stop(self) -> None:
"""Stop the AgentMail bot."""
if not self._is_running:
return
self._is_running = False
self._stop_event.set()
# Cancel poll task
if self._poll_task:
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
self._poll_task = None
# Cancel WS task
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
# Stop webhook server
if self._webhook_site:
await self._webhook_site.stop()
self._webhook_site = None
if self._webhook_runner:
await self._webhook_runner.cleanup()
self._webhook_runner = None
logger.info("AgentMailBot stopped")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Poll mode (existing behaviour)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _poll_loop(self, interval_override: Optional[int] = None) -> None:
"""Poll for new messages.
Args:
interval_override: Override polling interval (used by hybrid mode
to set a slower 60s fallback poll).
"""
interval = interval_override or self._polling_interval
while self._is_running:
try:
await self._check_new_messages()
except Exception as e:
logger.error(f"Error checking messages: {e}")
# Wait for interval or stop signal
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=interval
)
break # Stop event was set
except asyncio.TimeoutError:
continue # Normal timeout, continue polling
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# WebSocket mode
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _ws_loop(self) -> None:
"""WebSocket event loop with auto-reconnect.
Connects to AgentMail WebSocket API, subscribes to message.received
events for this inbox, and processes them via _handle_message.
Reconnects with exponential backoff on disconnection.
"""
backoff = 1 # Start at 1s
max_backoff = 60
while self._is_running:
try:
await self._ws_connect_and_listen()
# If we exit cleanly (stop called), break
if not self._is_running:
break
except asyncio.CancelledError:
raise
except Exception as e:
if not self._is_running:
break
logger.warning(
f"WebSocket disconnected: {e}. "
f"Reconnecting in {backoff}s..."
)
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=backoff
)
break # Stop was requested during backoff
except asyncio.TimeoutError:
pass
backoff = min(backoff * 2, max_backoff)
else:
# Reset backoff on clean disconnect
backoff = 1
async def _ws_connect_and_listen(self) -> None:
"""Single WebSocket connection session."""
try:
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
async_client = self._get_async_client()
async with async_client.websockets.connect() as ws:
# Subscribe to message.received events for this inbox
await ws.send_subscribe(
Subscribe(
event_types=["message.received"],
inbox_ids=[self._inbox_id] if self._inbox_id else None,
)
)
logger.info(f"WebSocket connected, subscribed to {self._inbox_id}")
async for event in ws:
if not self._is_running:
break
if isinstance(event, MessageReceivedEvent):
await self._handle_ws_event(event)
async def _handle_ws_event(self, event: Any) -> None:
"""Convert a WebSocket MessageReceivedEvent to BotMessage and handle."""
try:
msg_id = getattr(event, 'message_id', None)
if not msg_id:
return
# Dedup — shared with poll mode
if msg_id in self._processed_ids:
return
self._processed_ids.add(msg_id)
sender_raw = getattr(event, 'from_', '') or ''
sender_email = extract_email_address(sender_raw)
# Skip auto-replies and blocked senders
headers = getattr(event, 'headers', {}) or {}
if is_auto_reply(headers):
logger.debug(f"Skipping auto-reply from {sender_email}")
return
if is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
return
body = getattr(event, 'extracted_text', '') or getattr(event, 'text', '') or ''
message = BotMessage(
message_id=msg_id,
content=body,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=getattr(event, 'thread_id', None),
metadata={
"subject": getattr(event, 'subject', ''),
"message_id": msg_id,
"in_reply_to": getattr(event, 'in_reply_to', ''),
"source": "websocket",
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"Error handling WS event: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Webhook mode
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _start_webhook_mode(self) -> None:
"""Start an aiohttp webhook server to receive AgentMail events."""
try:
from aiohttp import web
except ImportError:
raise ImportError(
"aiohttp required for webhook mode. "
"Install with: pip install aiohttp"
)
app = web.Application()
webhook_path = self.config.webhook_path or "/webhook"
app.router.add_post(webhook_path, self._handle_email_webhook)
app.router.add_get("/health", self._handle_health_check)
self._webhook_runner = web.AppRunner(app)
await self._webhook_runner.setup()
self._webhook_site = web.TCPSite(
self._webhook_runner, "0.0.0.0", self._webhook_port
)
await self._webhook_site.start()
logger.info(
f"Webhook server listening on 0.0.0.0:{self._webhook_port}{webhook_path}"
)
# Register webhook with AgentMail API
if self.config.webhook_url:
try:
client = self._get_client()
webhook_url = self.config.webhook_url.rstrip("/") + webhook_path
client.webhooks.create(
url=webhook_url,
event_types=["message.received"],
inbox_ids=[self._inbox_id] if self._inbox_id else None,
)
logger.info(f"Registered webhook: {webhook_url}")
except Exception as e:
logger.warning(f"Failed to register webhook with AgentMail: {e}")
async def _handle_email_webhook(self, request: Any) -> Any:
"""Handle incoming webhook POST from AgentMail."""
from aiohttp import web
try:
body = await request.json()
except Exception:
return web.Response(status=400, text="Invalid JSON")
# Extract event type
event_type = body.get("type", "")
if event_type != "message.received":
return web.Response(status=200, text="OK")
# Process asynchronously — return 200 immediately
asyncio.create_task(self._process_webhook_payload(body))
return web.Response(status=200, text="OK")
async def _process_webhook_payload(self, body: Dict[str, Any]) -> None:
"""Process a webhook payload in the background."""
try:
data = body.get("data", body)
msg_id = data.get("message_id", "")
if not msg_id:
return
# Dedup
if msg_id in self._processed_ids:
return
self._processed_ids.add(msg_id)
sender_raw = data.get("from", data.get("from_", ""))
sender_email = extract_email_address(sender_raw)
headers = data.get("headers", {})
if is_auto_reply(headers):
return
if is_blocked_sender(sender_email):
return
body_text = data.get("extracted_text", "") or data.get("text", "")
message = BotMessage(
message_id=msg_id,
content=body_text,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=data.get("thread_id"),
metadata={
"subject": data.get("subject", ""),
"message_id": msg_id,
"in_reply_to": data.get("in_reply_to", ""),
"source": "webhook",
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"Error processing webhook payload: {e}")
async def _handle_health_check(self, request: Any) -> Any:
"""Health check endpoint for webhook server."""
from aiohttp import web
import json
health = await self.health()
return web.Response(
status=200 if health.ok else 503,
text=json.dumps(health.to_dict()),
content_type="application/json",
)
async def _check_new_messages(self) -> None:
"""Check for and process new messages."""
client = self._get_client()
try:
# SDK v0.4.7: list() returns ListMessagesResponse with .messages list
response = client.inboxes.messages.list(self._inbox_id)
msg_list = response.messages if hasattr(response, 'messages') else response
for msg in msg_list:
# SDK v0.4.7: Message uses .message_id, not .id
msg_id = msg.message_id
# Skip already processed
if msg_id in self._processed_ids:
continue
self._processed_ids.add(msg_id)
# SDK v0.4.7: Message uses .from_ (not .from_address)
sender_raw = getattr(msg, 'from_', '') or ''
sender_email = extract_email_address(sender_raw)
# Skip auto-replies and blocked senders
headers = getattr(msg, 'headers', {}) or {}
if is_auto_reply(headers):
logger.debug(f"Skipping auto-reply from {sender_email}")
continue
if is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
continue
# Build BotMessage — SDK uses .text / .extracted_text, not .body
body = getattr(msg, 'extracted_text', '') or getattr(msg, 'text', '') or ''
message = BotMessage(
message_id=msg_id,
content=body,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=getattr(msg, 'thread_id', None),
metadata={
"subject": getattr(msg, 'subject', ''),
"message_id": msg_id,
"in_reply_to": getattr(msg, 'in_reply_to', ''),
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"AgentMail API error: {e}")
async def _handle_message(self, message: BotMessage) -> None:
"""Handle an incoming email message."""
# Fire message hooks
self.fire_message_received(message)
# Check command prefix in subject
subject = message.metadata.get("subject", "")
if subject.startswith(self.config.command_prefix):
command = subject[len(self.config.command_prefix):].split()[0].lower()
if command in self._command_handlers:
try:
await self._command_handlers[command](message)
except Exception as e:
logger.error(f"Command handler error: {e}")
return
# Call registered message handlers
for handler in self._message_handlers:
try:
result = handler(message)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Message handler error: {e}")
# Process with agent if available
if self._agent:
sender_id = message.sender.user_id if message.sender else "unknown"
body = message.content
try:
response = await self._session.chat(
self._agent,
sender_id,
body,
)
if response:
# Reply to sender
reply_subject = f"Re: {subject}" if subject else "Re: Your message"
await self.send_message(
channel_id=sender_id,
content={"subject": reply_subject, "body": response},
reply_to=message.metadata.get("message_id"),
thread_id=message.thread_id,
)
except Exception as e:
logger.error(f"Agent processing error: {e}")
async def send_message(
self,
channel_id: str,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
**kwargs,
) -> BotMessage:
"""Send an email message.
Args:
channel_id: Recipient email address
content: Message content (str or {"subject": ..., "body": ...})
reply_to: Message-ID to reply to
thread_id: Thread ID for conversation tracking
Returns:
The sent message
"""
# Parse content
if isinstance(content, dict):
subject = content.get("subject", "Message from PraisonAI")
body = content.get("body", "")
else:
subject = "Message from PraisonAI"
body = str(content)
# Fire sending hook
self.fire_message_sending(channel_id, body)
client = self._get_client()
# Send via AgentMail API
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
# For replying to a message, use messages.reply() method
try:
if reply_to:
# reply_to contains the original message_id — use reply() method
result = client.inboxes.messages.reply(
self._inbox_id,
reply_to, # message_id of original message
text=body,
subject=subject,
)
else:
result = client.inboxes.messages.send(
self._inbox_id,
to=channel_id,
subject=subject,
text=body,
)
# SendMessageResponse has .message_id
message_id = getattr(result, 'message_id', str(uuid.uuid4()))
except Exception as e:
logger.error(f"Failed to send email: {e}")
raise
# Build response message
bot_message = BotMessage(
message_id=message_id,
content=body,
message_type=MessageType.TEXT,
sender=self._bot_user,
channel=BotChannel(channel_id=channel_id, name=channel_id),
reply_to=reply_to,
thread_id=thread_id,
metadata={"subject": subject},
)
# Fire sent hook
self.fire_message_sent(channel_id, body, message_id)
return bot_message
async def edit_message(
self,
channel_id: str,
message_id: str,
content: str,
**kwargs,
) -> BotMessage:
"""Edit a message (not supported for email - sends new message)."""
logger.warning("Email does not support message editing. Sending new message.")
return await self.send_message(channel_id, content, **kwargs)
async def delete_message(
self,
channel_id: str,
message_id: str,
**kwargs,
) -> bool:
"""Delete a message (not supported for email)."""
logger.warning("Email does not support message deletion.")
return False
def on_message(self, handler: Callable) -> Callable:
"""Register a message handler.
Args:
handler: Function to call for each message
Returns:
The handler function
"""
self._message_handlers.append(handler)
return handler
def on_command(self, command: str) -> Callable:
"""Register a command handler.
Args:
command: Command name (without prefix)
Returns:
Decorator function
"""
def decorator(handler: Callable) -> Callable:
self._command_handlers[command.lower()] = handler
return handler
return decorator
async def create_inbox(
self,
domain: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a new inbox programmatically.
Args:
domain: Custom domain for the inbox
**kwargs: Additional inbox parameters
Returns:
Inbox details including id and email_address
"""
client = self._get_client()
params = {}
if domain:
params["domain"] = domain
params.update(kwargs)
inbox = client.inboxes.create(**params)
# SDK v0.4.7: inbox_id IS the email address
return {
"id": inbox.inbox_id,
"email_address": inbox.inbox_id,
"display_name": getattr(inbox, "display_name", None),
}
async def list_inboxes(self) -> List[Dict[str, Any]]:
"""List all inboxes for this API key.
Returns:
List of inbox details
"""
client = self._get_client()
# SDK v0.4.7: list() returns response object with .inboxes list
result = client.inboxes.list()
inbox_list = result.inboxes if hasattr(result, 'inboxes') else result
return [
{
"id": inbox.inbox_id,
"email_address": inbox.inbox_id,
"display_name": getattr(inbox, "display_name", None),
}
for inbox in inbox_list
]
async def delete_inbox(self, inbox_id: str) -> bool:
"""Delete an inbox.
Args:
inbox_id: ID of inbox to delete
Returns:
True if deleted successfully
"""
client = self._get_client()
try:
client.inboxes.delete(inbox_id)
return True
except Exception as e:
logger.error(f"Failed to delete inbox: {e}")
return False
async def probe(self) -> ProbeResult:
"""Test AgentMail API connectivity."""
start_time = time.time()
try:
client = self._get_client()
# Test by listing inboxes
client.inboxes.list()
elapsed_ms = (time.time() - start_time) * 1000
return ProbeResult(
ok=True,
platform="agentmail",
elapsed_ms=elapsed_ms,
bot_username=self._email_address,
)
except ImportError:
return ProbeResult(
ok=False,
platform="agentmail",
error="agentmail package not installed",
)
except Exception as e:
return ProbeResult(
ok=False,
platform="agentmail",
error=str(e),
)
async def health(self) -> HealthResult:
"""Get health status of the AgentMail bot."""
probe_result = await self.probe()
uptime = 0.0
if self._started_at:
uptime = time.time() - self._started_at
return HealthResult(
ok=self._is_running and probe_result.ok,
platform="agentmail",
is_running=self._is_running,
uptime_seconds=uptime,
probe=probe_result,
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
)
+35 -16
View File
@@ -45,12 +45,23 @@ _TOKEN_ENV_MAP = {
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
"email": "EMAIL_APP_PASSWORD",
"agentmail": "AGENTMAIL_API_KEY",
}
# Slack also needs an app token
# Platform-specific extra env vars
_EXTRA_ENV_MAP = {
"slack": {"app_token": "SLACK_APP_TOKEN"},
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
"email": {
"email_address": "EMAIL_ADDRESS",
"imap_server": "EMAIL_IMAP_SERVER",
"smtp_server": "EMAIL_SMTP_SERVER",
},
"agentmail": {
"inbox_id": "AGENTMAIL_INBOX_ID",
"domain": "AGENTMAIL_DOMAIN",
},
}
@@ -170,22 +181,30 @@ class Bot:
current_tools = getattr(agent, 'tools', None) or []
if not current_tools:
try:
from praisonaiagents.tools import (
schedule_add, schedule_list, schedule_remove,
)
default_tools = [schedule_add, schedule_list, schedule_remove]
# Try to add search_web if available
from praisonaiagents.tools.profiles import resolve_profiles
from praisonaiagents.tools import ToolResolver
tool_names = resolve_profiles("web", "schedule", "memory", "learning")
resolver = ToolResolver()
default_tools = resolver.resolve_many(tool_names)
if default_tools:
agent.tools = default_tools
logger.debug(f"Bot: applied {len(default_tools)} default tools (via profiles) to agent '{getattr(agent, 'name', '?')}'")
except Exception:
# Fallback: hardcoded imports if profiles unavailable
try:
from praisonaiagents.tools import search_web
default_tools.insert(0, search_web)
except (ImportError, AttributeError):
pass
agent.tools = default_tools
logger.debug(f"Bot: applied default tools to agent '{getattr(agent, 'name', '?')}'")
except ImportError:
pass # Tools not available, skip
from praisonaiagents.tools import (
schedule_add, schedule_list, schedule_remove,
)
default_tools = [schedule_add, schedule_list, schedule_remove]
try:
from praisonaiagents.tools import search_web
default_tools.insert(0, search_web)
except (ImportError, AttributeError):
pass
agent.tools = default_tools
logger.debug(f"Bot: applied default tools (fallback) to agent '{getattr(agent, 'name', '?')}'")
except ImportError:
pass # Tools not available, skip
return agent
+9 -1
View File
@@ -82,7 +82,15 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="discord",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+598
View File
@@ -0,0 +1,598 @@
"""
Email Bot implementation for PraisonAI.
Provides an email bot runtime with IMAP polling and SMTP sending,
command handling, and agent integration.
Requires: pip install praisonai[email]
"""
from __future__ import annotations
import asyncio
import email
import email.utils
import hashlib
import imaplib
import logging
import os
import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
BotUser,
BotChannel,
MessageType,
ProbeResult,
HealthResult,
)
from ._session import BotSessionManager
from ._email_utils import is_auto_reply, is_blocked_sender
logger = logging.getLogger(__name__)
class EmailBot(ChatCommandMixin, MessageHookMixin):
"""Email bot runtime for PraisonAI agents.
Connects an agent to email via IMAP/SMTP, handling incoming emails
as messages and sending replies.
Example:
from praisonai.bots import EmailBot
from praisonaiagents import Agent
agent = Agent(name="email_assistant")
bot = EmailBot(
token="app_password", # Gmail app password
email_address="[email protected]",
imap_server="imap.gmail.com",
smtp_server="smtp.gmail.com",
agent=agent,
)
await bot.start()
Environment variables (if not passed directly):
EMAIL_APP_PASSWORD: App password for authentication
EMAIL_ADDRESS: Email address for the bot
EMAIL_IMAP_SERVER: IMAP server (default: imap.gmail.com)
EMAIL_SMTP_SERVER: SMTP server (default: smtp.gmail.com)
"""
def __init__(
self,
token: str,
agent: Optional["Agent"] = None,
config: Optional[BotConfig] = None,
email_address: Optional[str] = None,
imap_server: Optional[str] = None,
smtp_server: Optional[str] = None,
imap_port: int = 993,
smtp_port: int = 587,
**kwargs,
):
"""Initialize the Email bot.
Args:
token: App password for email authentication
agent: Optional agent to handle messages
config: Optional bot configuration
email_address: Bot's email address (or EMAIL_ADDRESS env var)
imap_server: IMAP server hostname (or EMAIL_IMAP_SERVER env var)
smtp_server: SMTP server hostname (or EMAIL_SMTP_SERVER env var)
imap_port: IMAP port (default: 993 for SSL)
smtp_port: SMTP port (default: 587 for STARTTLS)
**kwargs: Additional arguments for forward compatibility
"""
self._extra_kwargs = kwargs
self._token = token
self._agent = agent
self.config = config or BotConfig(token=token)
# Email-specific config
self._email_address = email_address or os.environ.get("EMAIL_ADDRESS", "")
self._imap_server = imap_server or os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com")
self._smtp_server = smtp_server or os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com")
self._imap_port = imap_port
self._smtp_port = smtp_port
self._is_running = False
self._bot_user: Optional[BotUser] = None
self._imap: Optional[imaplib.IMAP4_SSL] = None
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._processed_uids: set = set() # Track processed email UIDs
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="email",
)
self._stop_event: Optional[asyncio.Event] = None
self._poll_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
return self._is_running
@property
def platform(self) -> str:
return "email"
@property
def bot_user(self) -> Optional[BotUser]:
return self._bot_user
def set_agent(self, agent: "Agent") -> None:
"""Set the agent that handles messages."""
self._agent = agent
def get_agent(self) -> Optional["Agent"]:
"""Get the current agent."""
return self._agent
async def start(self) -> None:
"""Start the Email bot (begin IMAP polling)."""
if self._is_running:
logger.warning("EmailBot already running")
return
if not self._email_address:
raise ValueError(
"Email address required. Set EMAIL_ADDRESS env var or pass email_address parameter."
)
if not self._token:
raise ValueError(
"App password required. Set EMAIL_APP_PASSWORD env var or pass token parameter."
)
# Test connection
probe = await self.probe()
if not probe.ok:
raise ConnectionError(f"Failed to connect to email server: {probe.error}")
self._bot_user = BotUser(
user_id=self._email_address,
username=self._email_address,
display_name=self._email_address.split("@")[0],
is_bot=True,
)
self._is_running = True
self._started_at = time.time()
self._stop_event = asyncio.Event()
# Start polling loop
self._poll_task = asyncio.create_task(self._poll_loop())
logger.info(f"EmailBot started for {self._email_address}")
async def stop(self) -> None:
"""Stop the Email bot."""
if not self._is_running:
return
self._is_running = False
if self._stop_event:
self._stop_event.set()
if self._poll_task:
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
if self._imap:
try:
self._imap.logout()
except Exception:
pass
self._imap = None
logger.info("EmailBot stopped")
async def _poll_loop(self) -> None:
"""Main polling loop for checking new emails."""
poll_interval = self.config.polling_interval or 30.0 # Default 30s for email
while self._is_running:
try:
await self._check_new_emails()
except Exception as e:
logger.error(f"Error checking emails: {e}")
# Wait for next poll or stop signal
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=poll_interval,
)
break # Stop event was set
except asyncio.TimeoutError:
continue # Normal timeout, continue polling
async def _check_new_emails(self) -> None:
"""Check for new unread emails and process them."""
loop = asyncio.get_event_loop()
try:
# Connect to IMAP
imap = await loop.run_in_executor(
None,
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
)
await loop.run_in_executor(
None,
lambda: imap.login(self._email_address, self._token)
)
await loop.run_in_executor(None, lambda: imap.select("INBOX"))
# Search for unread emails
_, message_numbers = await loop.run_in_executor(
None,
lambda: imap.search(None, "UNSEEN")
)
for num in message_numbers[0].split():
if not self._is_running:
break
uid = num.decode()
if uid in self._processed_uids:
continue
try:
_, msg_data = await loop.run_in_executor(
None,
lambda n=num: imap.fetch(n, "(RFC822)")
)
if msg_data[0] is None:
continue
raw_email = msg_data[0][1]
email_message = email.message_from_bytes(raw_email)
# Check for auto-reply headers
if self._is_auto_reply(email_message):
logger.debug(f"Skipping auto-reply email: {uid}")
self._processed_uids.add(uid)
continue
# Parse sender
from_header = email_message.get("From", "")
sender_name, sender_email = email.utils.parseaddr(from_header)
# Check for blocked senders
if self._is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
self._processed_uids.add(uid)
continue
# Extract body
body = self._extract_body(email_message)
subject = email_message.get("Subject", "(No Subject)")
message_id = email_message.get("Message-ID", uid)
references = email_message.get("References", "")
in_reply_to = email_message.get("In-Reply-To", "")
# Create BotMessage
bot_message = BotMessage(
message_id=message_id,
content=f"Subject: {subject}\n\n{body}",
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_name or sender_email,
),
channel=BotChannel(
channel_id=sender_email,
name=sender_email,
channel_type="dm",
),
reply_to=in_reply_to or None,
thread_id=references or message_id,
metadata={
"subject": subject,
"message_id": message_id,
"references": references,
"in_reply_to": in_reply_to,
},
)
self._processed_uids.add(uid)
# Process message
await self._handle_message(bot_message)
except Exception as e:
logger.error(f"Error processing email {uid}: {e}")
await loop.run_in_executor(None, imap.logout)
except Exception as e:
logger.error(f"IMAP error: {e}")
def _is_auto_reply(self, msg: email.message.Message) -> bool:
"""Check if email is an auto-reply."""
headers = {k: v for k, v in msg.items()}
return is_auto_reply(headers)
def _is_blocked_sender(self, sender: str) -> bool:
"""Check if sender should be blocked."""
return is_blocked_sender(sender)
def _extract_body(self, msg: email.message.Message) -> str:
"""Extract plain text body from email."""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
async def _handle_message(self, message: BotMessage) -> None:
"""Handle an incoming email message."""
# Fire message hooks
self.fire_message_received(message)
# Check command prefix in subject
subject = message.metadata.get("subject", "")
if subject.startswith(self.config.command_prefix):
command = subject[len(self.config.command_prefix):].split()[0].lower()
if command in self._command_handlers:
try:
await self._command_handlers[command](message)
except Exception as e:
logger.error(f"Command handler error: {e}")
return
# Call registered message handlers
for handler in self._message_handlers:
try:
result = handler(message)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Message handler error: {e}")
# Process with agent if available
if self._agent:
try:
# Extract just the body for the agent
body = message.content
if body.startswith("Subject:"):
body = body.split("\n\n", 1)[-1]
response = await self._session.chat(
self._agent,
message.sender.user_id,
body,
)
if response:
# Reply to the email
await self.send_message(
channel_id=message.sender.user_id,
content={
"subject": f"Re: {message.metadata.get('subject', '')}",
"body": response,
},
reply_to=message.message_id,
thread_id=message.thread_id,
)
except Exception as e:
logger.error(f"Agent processing error: {e}")
async def send_message(
self,
channel_id: str,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
) -> BotMessage:
"""Send an email message.
Args:
channel_id: Recipient email address
content: Message content (str or {"subject": ..., "body": ...})
reply_to: Message-ID to reply to (sets In-Reply-To header)
thread_id: References chain (sets References header)
Returns:
The sent message
"""
# Fire sending hook
content_str = content.get("body", "") if isinstance(content, dict) else str(content)
self.fire_message_sending(channel_id, content_str)
# Parse content
if isinstance(content, dict):
subject = content.get("subject", "Message from PraisonAI")
body = content.get("body", "")
html = content.get("html")
else:
subject = "Message from PraisonAI"
body = str(content)
html = None
# Build email
if html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain"))
msg.attach(MIMEText(html, "html"))
else:
msg = MIMEText(body, "plain")
msg["From"] = self._email_address
msg["To"] = channel_id
msg["Subject"] = subject
# Generate unique Message-ID
domain = self._email_address.split("@")[-1]
message_id = f"<{hashlib.md5(f'{time.time()}{channel_id}'.encode()).hexdigest()}@{domain}>"
msg["Message-ID"] = message_id
# Threading headers
if reply_to:
msg["In-Reply-To"] = reply_to
if thread_id:
msg["References"] = thread_id
# Add auto-reply header to prevent loops
msg["X-Auto-Reply"] = "yes"
msg["Auto-Submitted"] = "auto-replied"
# Send via SMTP
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._send_smtp, msg)
# Create BotMessage for return
bot_message = BotMessage(
message_id=message_id,
content=body,
message_type=MessageType.TEXT,
sender=self._bot_user,
channel=BotChannel(channel_id=channel_id, name=channel_id),
reply_to=reply_to,
thread_id=thread_id,
metadata={"subject": subject},
)
# Fire sent hook
self.fire_message_sent(channel_id, body, message_id)
return bot_message
def _send_smtp(self, msg: MIMEText) -> None:
"""Send email via SMTP (sync, runs in executor)."""
with smtplib.SMTP(self._smtp_server, self._smtp_port) as server:
server.starttls()
server.login(self._email_address, self._token)
server.send_message(msg)
async def edit_message(
self,
channel_id: str,
message_id: str,
content: Union[str, Dict[str, Any]],
) -> BotMessage:
"""Edit a message (not supported for email - sends new message)."""
logger.warning("Email does not support message editing. Sending new message.")
return await self.send_message(channel_id, content)
async def delete_message(self, channel_id: str, message_id: str) -> bool:
"""Delete a message (not supported for email)."""
logger.warning("Email does not support message deletion.")
return False
def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable:
"""Register a message handler."""
self._message_handlers.append(handler)
return handler
def on_command(self, command: str) -> Callable:
"""Decorator to register a command handler.
Commands are detected from email subject lines starting with
the command prefix (default: "/").
Example:
@bot.on_command("status")
async def handle_status(message):
await bot.send_message(message.channel.channel_id, "Bot is running!")
"""
def decorator(func: Callable) -> Callable:
self._command_handlers[command.lower()] = func
return func
return decorator
async def send_typing(self, channel_id: str) -> None:
"""Send typing indicator (no-op for email)."""
pass
async def get_user(self, user_id: str) -> Optional[BotUser]:
"""Get user information."""
return BotUser(user_id=user_id, username=user_id)
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
"""Get channel information."""
return BotChannel(channel_id=channel_id, name=channel_id, channel_type="dm")
async def probe(self) -> ProbeResult:
"""Test email server connectivity."""
start_time = time.time()
try:
loop = asyncio.get_event_loop()
# Test IMAP
imap = await loop.run_in_executor(
None,
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
)
await loop.run_in_executor(
None,
lambda: imap.login(self._email_address, self._token)
)
await loop.run_in_executor(None, imap.logout)
elapsed_ms = (time.time() - start_time) * 1000
return ProbeResult(
ok=True,
platform="email",
elapsed_ms=elapsed_ms,
bot_username=self._email_address,
)
except Exception as e:
return ProbeResult(
ok=False,
platform="email",
error=str(e),
)
async def health(self) -> HealthResult:
"""Get health status of the email bot."""
probe_result = await self.probe()
uptime = 0.0
if self._started_at:
uptime = time.time() - self._started_at
return HealthResult(
ok=self._is_running and probe_result.ok,
platform="email",
is_running=self._is_running,
uptime_seconds=uptime,
probe=probe_result,
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
)
+9 -1
View File
@@ -95,7 +95,15 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="slack",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+9 -1
View File
@@ -85,7 +85,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="telegram",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+9 -1
View File
@@ -130,7 +130,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
self._is_running = False
self._started_at: Optional[float] = None
self._bot_user: Optional[BotUser] = None
self._session_mgr = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session_mgr = BotSessionManager(
store=_store,
platform="whatsapp",
)
self._message_handlers: List[Callable] = []
self._runner: Any = None
self._site: Any = None
+1
View File
@@ -0,0 +1 @@
"""PraisonAI Claw — bundled default configuration."""
+112
View File
@@ -0,0 +1,112 @@
"""PraisonAIUI — Default App (Dashboard with Agents + Feature Explorer).
Loaded by ``praisonai claw`` → ``aiui run app.py``.
All built-in sidebar pages are enabled by default.
Agents: Researcher, Writer, Coder
Custom page: Feature Explorer
"""
import os
import praisonaiui as aiui
# ── Dashboard style ─────────────────────────────────────────
aiui.set_style("dashboard")
aiui.set_pages([
"chat", # Core AI chat
"channels", # Messaging platforms
"agents", # Agent management
"skills", # Available tools
"memory", # Agent memory
"knowledge", # Knowledge base
"cron", # Scheduled jobs
"guardrails", # Safety rules
"sessions", # Conversation history
"usage", # Token usage & costs
"config", # Runtime configuration
"logs", # Server logs
"debug", # System debug info
])
# ── Agent definitions ───────────────────────────────────────
AGENTS = [
{
"agent_id": "researcher",
"name": "Researcher",
"description": "Research assistant — finds and summarizes information",
"instructions": (
"You are a research assistant. Provide well-structured answers "
"with key facts. Keep responses concise but informative."
),
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
"icon": "🔬",
},
{
"agent_id": "writer",
"name": "Writer",
"description": "Creative writer — generates content on any topic",
"instructions": (
"You are a creative writer. Write engaging content in a clear, "
"professional style. Keep responses focused and well-organized."
),
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
"icon": "✍️",
},
{
"agent_id": "coder",
"name": "Coder",
"description": "Coding assistant — Python, JavaScript, and more",
"instructions": (
"You are a coding assistant. Write clean, well-commented "
"code with explanations. Support Python, JavaScript, and "
"other popular languages."
),
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
"icon": "💻",
},
]
# ── Custom page: Feature Explorer ───────────────────────────
@aiui.page(
"explorer",
title="Explorer",
icon="🔬",
group="Control",
order=55,
)
async def explorer_page():
"""Three-column feature explorer — custom view renders in JS."""
return aiui.layout([
aiui.text("Feature Explorer loads via custom view module."),
])
# ── Register agents in dashboard CRUD ───────────────────────
def _register_agents_in_dashboard():
"""Bridge agents into the dashboard agents_crud feature."""
from praisonaiui.features.agents import get_agent_registry
registry = get_agent_registry()
existing = registry.list_all()
existing_names = {a.get("name") for a in existing}
for agent_def in AGENTS:
if agent_def["name"] in existing_names:
continue
registry.create({
"name": agent_def["name"],
"description": agent_def["description"],
"instructions": agent_def["instructions"],
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
try:
_register_agents_in_dashboard()
except Exception as e:
print(f" ⚠️ Agent registration failed (non-fatal): {e}")
+2
View File
@@ -229,6 +229,7 @@ def register_commands():
from .commands.browser import app as browser_app
from .commands.plugins import app as plugins_app
from .commands.sandbox import app as sandbox_app
from .commands.claw import app as claw_app
# Import TUI and queue commands
from .features.tui.debug import create_debug_app as create_tui_debug_app
@@ -406,6 +407,7 @@ def register_commands():
app.add_typer(browser_app, name="browser", help="Browser control for agent automation")
app.add_typer(plugins_app, name="plugins", help="Plugin management and inspection")
app.add_typer(sandbox_app, name="sandbox", help="Sandbox container management")
app.add_typer(claw_app, name="claw", help="🦞 PraisonAI Dashboard (full UI)")
# Register standardise command
try:
@@ -355,6 +355,89 @@ def bot_whatsapp(
)
@app.command("email")
def bot_email(
token: Optional[str] = typer.Option(None, "--token", "-t", help="Email app password", envvar="EMAIL_APP_PASSWORD"),
email_address: Optional[str] = typer.Option(None, "--email", help="Email address", envvar="EMAIL_ADDRESS"),
imap_server: Optional[str] = typer.Option(None, "--imap", help="IMAP server"),
smtp_server: Optional[str] = typer.Option(None, "--smtp", help="SMTP server"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
):
"""Start an Email bot (IMAP/SMTP).
Examples:
praisonai bot email --token $EMAIL_APP_PASSWORD --email user@gmail.com
praisonai bot email --agent agents.yaml --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
tools=tools or [],
memory=memory,
knowledge=knowledge,
web_search=web_search,
auto_approve=auto_approve,
model=model,
)
handler = BotHandler()
handler.start_email(
token=token,
agent_file=agent,
capabilities=capabilities,
email_address=email_address,
imap_server=imap_server,
smtp_server=smtp_server,
)
@app.command("agentmail")
def bot_agentmail(
token: Optional[str] = typer.Option(None, "--token", "-t", help="AgentMail API key", envvar="AGENTMAIL_API_KEY"),
inbox_id: Optional[str] = typer.Option(None, "--inbox", help="Existing inbox ID / email address", envvar="AGENTMAIL_INBOX_ID"),
domain: Optional[str] = typer.Option(None, "--domain", help="Custom domain for new inboxes", envvar="AGENTMAIL_DOMAIN"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
):
"""Start an AgentMail bot (API-first email for AI agents).
Examples:
praisonai bot agentmail --token $AGENTMAIL_API_KEY
praisonai bot agentmail --inbox praison@agentmail.to
praisonai bot agentmail --agent agents.yaml --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
tools=tools or [],
memory=memory,
knowledge=knowledge,
web_search=web_search,
auto_approve=auto_approve,
model=model,
)
handler = BotHandler()
handler.start_agentmail(
token=token,
agent_file=agent,
capabilities=capabilities,
inbox_id=inbox_id,
domain=domain,
)
@app.callback(invoke_without_command=True)
def bot_callback(ctx: typer.Context):
"""Show bot help if no subcommand provided."""
@@ -369,6 +452,8 @@ Start a bot on any platform with: praisonai bot <platform>
[green]discord[/green] Discord Bot API
[green]slack[/green] Slack Socket Mode
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
[green]email[/green] Email via IMAP/SMTP
[green]agentmail[/green] AgentMail API (API-first email for AI agents)
[bold]Capability Options (all platforms):[/bold]
[yellow]--agent FILE[/yellow] Agent YAML configuration
@@ -0,0 +1,103 @@
"""
Claw command — launch the PraisonAI 🦞 Dashboard.
Usage:
praisonai claw # Dashboard on :8082
praisonai claw --port 9000 # Custom port
praisonai claw --app my.py # Custom app file
"""
import sys
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(help="🦞 PraisonAI Dashboard (full UI)")
CLAW_DIR = Path.home() / ".praisonai" / "claw"
DEFAULT_APP = CLAW_DIR / "app.py"
def _ensure_default_app() -> Path:
"""Copy bundled default_app.py to ~/.praisonai/claw/app.py if missing."""
if DEFAULT_APP.exists():
return DEFAULT_APP
CLAW_DIR.mkdir(parents=True, exist_ok=True)
# Read the bundled default
bundled = Path(__file__).parent.parent.parent / "claw" / "default_app.py"
if not bundled.exists():
print(f"[red]ERROR: Bundled default_app.py not found at {bundled}[/red]")
raise typer.Abort()
DEFAULT_APP.write_text(bundled.read_text())
print(f" ✓ Created default dashboard config: {DEFAULT_APP}")
return DEFAULT_APP
@app.callback(invoke_without_command=True)
def claw(
ctx: typer.Context,
port: int = typer.Option(8082, "--port", "-p", help="Port to run dashboard on"),
host: str = typer.Option("0.0.0.0", "--host", help="Host to bind to"),
app_file: Optional[str] = typer.Option(
None, "--app", "-a", help="Custom app.py file (default: ~/.praisonai/claw/app.py)"
),
reload: bool = typer.Option(False, "--reload", "-r", help="Enable auto-reload"),
):
"""
Launch the PraisonAI 🦞 Dashboard.
Starts the AIUI dashboard with chat, agents, memory, knowledge,
channels, cron, guardrails, and more.
Examples:
praisonai claw
praisonai claw --port 9000
praisonai claw --app my-dashboard.py
"""
if ctx.invoked_subcommand is not None:
return
# 1. Check praisonaiui is installed
try:
import importlib.util
if importlib.util.find_spec("praisonaiui") is None:
raise ImportError
except ImportError:
print("\n\033[91mERROR: PraisonAI Dashboard (aiui) is not installed.\033[0m")
print('\nInstall with:\n pip install "praisonai[claw]"\n')
sys.exit(1)
# 2. Resolve app file
if app_file:
resolved = Path(app_file)
if not resolved.exists():
print(f"\033[91mERROR: App file not found: {app_file}\033[0m")
sys.exit(1)
else:
resolved = _ensure_default_app()
# 3. Launch via aiui run
import subprocess
cmd = ["aiui", "run", str(resolved), "--port", str(port), "--host", host]
if reload:
cmd.append("--reload")
print(f"\n🦞 PraisonAI Dashboard starting at http://{host}:{port}")
print(f" App: {resolved}\n")
try:
subprocess.run(cmd, check=True)
except FileNotFoundError:
# Fallback: python -m praisonaiui.cli
cmd = [sys.executable, "-m", "praisonaiui.cli", "run", str(resolved),
"--port", str(port), "--host", host]
if reload:
cmd.append("--reload")
subprocess.run(cmd, check=True)
except KeyboardInterrupt:
print("\n🦞 Dashboard stopped.")
@@ -42,15 +42,17 @@ def schedule_add_cmd(
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
channel: str = typer.Option("", "--channel", help="Delivery platform: telegram, discord, slack, whatsapp"),
channel_id: str = typer.Option("", "--channel-id", help="Target chat/channel ID on the platform"),
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Add a job to the schedule store (with optional delivery target).
Examples:
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
praisonai schedule add "tg-reminder" -s daily -m "check email" --channel telegram --channel-id 12345
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
"""
output = get_output_controller()
try:
@@ -60,8 +62,10 @@ def schedule_add_cmd(
name=name,
schedule=schedule,
message=message,
agent_id=agent,
channel=channel,
channel_id=channel_id,
session_id=session_id,
)
if json_output:
@@ -190,12 +190,25 @@ class BotHandler:
platform = config.get("platform")
if not platform:
print("Error: 'platform' is required in bot config (telegram, discord, or slack)")
return
# Also try channels-style config (multi-channel format)
channels = config.get("channels")
if channels and isinstance(channels, dict):
# Pick first channel as platform
platform = next(iter(channels))
# Merge channel config into top level for token resolution
channel_conf = channels[platform] or {}
if "token" not in config and channel_conf.get("token"):
config["token"] = channel_conf["token"]
if "bot_token" not in config and channel_conf.get("bot_token"):
config["token"] = channel_conf["bot_token"]
else:
print("Error: 'platform' or 'channels' is required in bot config")
return
platform = platform.lower().strip()
if platform not in ("telegram", "discord", "slack", "whatsapp"):
print(f"Error: Unknown platform '{platform}'. Use: telegram, discord, slack, whatsapp")
valid_platforms = ("telegram", "discord", "slack", "whatsapp", "email", "agentmail")
if platform not in valid_platforms:
print(f"Error: Unknown platform '{platform}'. Use: {', '.join(valid_platforms)}")
return
token = resolve_env_vars(config.get("token", ""))
@@ -272,6 +285,25 @@ class BotHandler:
allowed_groups=wa_allowed_groups,
respond_to_all=bool(wa_respond_to_all),
)
elif platform == "email":
self.start_email(
token=token or None,
agent_file=None,
capabilities=capabilities,
agent_config_dict=agent_config,
email_address=resolve_env_vars(config.get("email_address", "")),
imap_server=resolve_env_vars(config.get("imap_server", "")),
smtp_server=resolve_env_vars(config.get("smtp_server", "")),
)
elif platform == "agentmail":
self.start_agentmail(
token=token or None,
agent_file=None,
capabilities=capabilities,
agent_config_dict=agent_config,
inbox_id=resolve_env_vars(config.get("inbox_id", "")),
domain=resolve_env_vars(config.get("domain", "")),
)
def start_telegram(
self,
@@ -522,6 +554,117 @@ class BotHandler:
except Exception:
pass # neonize Go threads may already be gone
def start_email(
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
agent_config_dict: Optional[Dict[str, Any]] = None,
email_address: Optional[str] = None,
imap_server: Optional[str] = None,
smtp_server: Optional[str] = None,
) -> None:
"""Start an Email bot (IMAP/SMTP).
Args:
token: Email app password (or EMAIL_APP_PASSWORD env var)
agent_file: Optional path to agent config file
capabilities: Optional capabilities configuration
agent_config_dict: Optional agent config dict from bot YAML
email_address: Email address to use
imap_server: IMAP server hostname
smtp_server: SMTP server hostname
"""
self._load_dotenv()
token = token or os.environ.get("EMAIL_APP_PASSWORD")
if not token:
print("Error: Email app password required")
print("Provide via --token or EMAIL_APP_PASSWORD environment variable")
return
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
try:
from praisonai.bots import EmailBot
except ImportError as e:
print(f"Error: EmailBot import failed. {e}")
return
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
bot = EmailBot(
token=token,
agent=agent,
email_address=email_address or None,
imap_server=imap_server or None,
smtp_server=smtp_server or None,
)
self._print_startup_info("Email (IMAP/SMTP)", capabilities)
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
print("\nStopping bot...")
asyncio.run(bot.stop())
def start_agentmail(
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
agent_config_dict: Optional[Dict[str, Any]] = None,
inbox_id: Optional[str] = None,
domain: Optional[str] = None,
) -> None:
"""Start an AgentMail bot (API-first email).
Args:
token: AgentMail API key (or AGENTMAIL_API_KEY env var)
agent_file: Optional path to agent config file
capabilities: Optional capabilities configuration
agent_config_dict: Optional agent config dict from bot YAML
inbox_id: Existing inbox ID / email address to reuse
domain: Custom domain for new inboxes
"""
self._load_dotenv()
token = token or os.environ.get("AGENTMAIL_API_KEY")
if not token:
print("Error: AgentMail API key required")
print("Provide via --token or AGENTMAIL_API_KEY environment variable")
return
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
try:
from praisonai.bots import AgentMailBot
except ImportError as e:
print(f"Error: AgentMailBot import failed. {e}")
print("Install with: pip install agentmail")
return
inbox_id = inbox_id or os.environ.get("AGENTMAIL_INBOX_ID")
domain = domain or os.environ.get("AGENTMAIL_DOMAIN")
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
bot = AgentMailBot(
token=token,
agent=agent,
inbox_id=inbox_id,
domain=domain,
)
self._print_startup_info("AgentMail", capabilities)
if inbox_id:
print(f"Inbox: {inbox_id}")
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
print("\nStopping bot...")
asyncio.run(bot.stop())
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
+31 -1
View File
@@ -5,10 +5,25 @@ Provides Model Context Protocol server integration.
Usage: praisonai "prompt" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
"""
import os
import shlex
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
# Executables allowed as MCP server commands from the CLI.
# The SDK MCP() class is intentionally unrestricted — developers control their own inputs.
ALLOWED_MCP_COMMANDS = {
"npx", "npx.cmd", "npx.exe",
"node", "node.exe",
"python", "python3", "python.exe", "python3.exe",
"uvx", "uvx.exe",
"uv", "uv.exe",
"docker", "docker.exe",
"deno", "deno.exe",
"bun", "bun.exe",
"pipx",
}
class MCPHandler(FlagHandler):
"""
@@ -56,6 +71,9 @@ class MCPHandler(FlagHandler):
Returns:
Tuple of (command, args, env_dict)
Raises:
ValueError: If the command executable is not in the allowed list.
"""
# Parse command using shell-like splitting
parts = shlex.split(command)
@@ -65,6 +83,14 @@ class MCPHandler(FlagHandler):
cmd = parts[0]
args = parts[1:] if len(parts) > 1 else []
# Validate executable against allowlist
basename = os.path.basename(cmd)
if basename not in ALLOWED_MCP_COMMANDS:
raise ValueError(
f"Command '{cmd}' is not in the allowed MCP executables list. "
f"Allowed: {', '.join(sorted(ALLOWED_MCP_COMMANDS - {c for c in ALLOWED_MCP_COMMANDS if '.' in c}))}"
)
# Parse environment variables
env = {}
if env_vars:
@@ -94,7 +120,11 @@ class MCPHandler(FlagHandler):
from praisonaiagents import MCP
cmd, args, env = self.parse_mcp_command(command, env_vars)
try:
cmd, args, env = self.parse_mcp_command(command, env_vars)
except ValueError as e:
self.print_status(str(e), "error")
return None
if not cmd:
self.print_status("Invalid MCP command", "error")
+39 -6
View File
@@ -325,6 +325,10 @@ class PraisonAI:
# Update config_list model if --model flag is provided
if getattr(args, 'model', None):
self.config_list[0]['model'] = args.model
# Bridge --model to args.llm so direct prompts also use it
# (args.llm is what handle_direct_prompt checks for Agent config)
if not getattr(args, 'llm', None):
args.llm = args.model
# Check for piped input from stdin
stdin_input = self.read_stdin_if_available()
@@ -801,7 +805,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths', 'claw']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -1643,6 +1647,19 @@ class PraisonAI:
sys.exit(e.code if e.code else 0)
sys.exit(0)
elif args.command == 'claw':
# Claw command - 🦞 PraisonAI Dashboard (full UI)
# Routes to Typer CLI for claw command (launches AIUI dashboard)
from .app import app as typer_app, register_commands
register_commands()
import sys as _sys
_sys.argv = ['praisonai', 'claw'] + unknown_args
try:
typer_app()
except SystemExit as e:
sys.exit(e.code if e.code else 0)
sys.exit(0)
elif args.command == 'registry':
# Registry command - manage recipe registry server
from .features.registry import RegistryHandler
@@ -3504,6 +3521,21 @@ class PraisonAI:
return issues
@staticmethod
def _clean_commit_message(raw: str) -> str:
"""Strip markdown code fences and excess whitespace from LLM-generated commit messages."""
msg = raw.strip()
# Remove wrapping ```...``` (with optional language tag like ```text)
if msg.startswith("```"):
lines = msg.split("\n")
# Remove first line (```<lang?>) and last line (```) if present
if len(lines) >= 2 and lines[-1].strip() == "```":
lines = lines[1:-1]
elif lines[0].strip().startswith("```"):
lines = lines[1:]
msg = "\n".join(lines).strip()
return msg
def handle_commit_command(self, args: list):
"""
Handle AI commit message generation.
@@ -3632,10 +3664,12 @@ Be specific about what changed and why.""",
Detailed diff:
{diff_content}
Provide ONLY the commit message, no explanations."""
Provide ONLY the raw commit message text.
Do NOT wrap it in markdown code fences or backticks.
Do NOT add any explanations or formatting."""
response = agent.chat(prompt)
commit_message = response.strip()
commit_message = self._clean_commit_message(response)
print("\n[bold green]Suggested commit message:[/bold green]")
print(f"[cyan]{commit_message}[/cyan]")
@@ -4423,10 +4457,9 @@ Provide ONLY the commit message, no explanations."""
else:
result = agent.chat(prompt)
# Display final result
# SDK callbacks (interaction, llm_content) handle display —
# no explicit editor.output() needed here.
output = result.output if hasattr(result, 'output') else str(result)
if output:
editor.output(output)
# Show summary
elapsed = editor.elapsed_time()
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.38 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.73 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+165 -27
View File
@@ -865,20 +865,24 @@ class WebSocketGateway:
elif not isinstance(raw["channels"], dict) or not raw["channels"]:
errors.append("'channels' must be a non-empty dictionary")
else:
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
for cname, cdef in raw["channels"].items():
if not isinstance(cdef, dict):
errors.append(f"Channel '{cname}' must be a dictionary")
continue
if cname.lower() not in valid_platforms:
# Resolve platform: explicit field takes priority over key
platform = cdef.get("platform", cname).lower()
if platform not in valid_platforms:
logger.warning(
f"Channel '{cname}' is not a known platform "
f"({', '.join(valid_platforms)})"
f"Channel '{cname}' platform '{platform}' is not "
f"a known platform ({', '.join(sorted(valid_platforms))})"
)
# WhatsApp web mode doesn't require a token
is_wa_web = (cname.lower() == "whatsapp" and
is_wa_web = (platform == "whatsapp" and
cdef.get("mode", "cloud").lower().strip() == "web")
if not cdef.get("token") and not is_wa_web:
# Email/AgentMail use env vars for tokens — not required in YAML
is_email_platform = platform in ("email", "agentmail")
if not cdef.get("token") and not is_wa_web and not is_email_platform:
errors.append(
f"Channel '{cname}' missing 'token' "
"(use ${{ENV_VAR}} syntax for env vars)"
@@ -902,9 +906,15 @@ class WebSocketGateway:
return _resolve(raw)
def _create_agents_from_config(self, agents_cfg: Dict[str, Dict[str, Any]]) -> None:
"""Create and register Agent instances from the agents section of gateway.yaml.
def _create_agents_from_config(
self,
agents_cfg: Dict[str, Dict[str, Any]],
*,
default_model: Optional[str] = None,
guardrails_cfg: Optional[Dict[str, Any]] = None,
) -> None:
"""Create and register Agent instances from the agents section of config.
Supports all agent configuration options including:
- tools: List of tool names to resolve via ToolResolver
- tool_choice: Tool selection mode ('auto', 'required', 'none')
@@ -913,10 +923,19 @@ class WebSocketGateway:
- role: Agent role (CrewAI-style)
- goal: Agent goal (CrewAI-style)
- backstory: Agent backstory (CrewAI-style)
- temperature: LLM temperature (optional, uses SDK default)
- base_url: Custom LLM endpoint URL (optional)
- api_key: API key for LLM provider (optional)
Args:
agents_cfg: The ``agents`` section of the config.
default_model: Fallback model when an agent has no ``model`` key
(e.g. from the ``provider.model`` section of UI config).
guardrails_cfg: Optional ``guardrails.registry`` dict to wire per-agent.
"""
from praisonaiagents import Agent
# G1: Resolve tool names to callables (same pattern as agents_generator)
# Resolve tool names to callables (same pattern as agents_generator)
tool_resolver = None
try:
from praisonai.tool_resolver import ToolResolver
@@ -926,15 +945,26 @@ class WebSocketGateway:
for agent_id, agent_def in agents_cfg.items():
instructions = agent_def.get("instructions", "")
model = agent_def.get("model", None)
# G7: Apply provider.model as fallback when agent has no model
model = agent_def.get("model", None) or default_model
memory = agent_def.get("memory", False)
# G4: Support role/goal/backstory for CrewAI-style agents
# G2: Pass temperature through (optional, SDK uses its own default)
temperature = agent_def.get("temperature", None)
# G3/G4: Pass base_url and api_key for multi-provider support
base_url = agent_def.get("base_url", None)
api_key = agent_def.get("api_key", None)
# Support role/goal/backstory for CrewAI-style agents
role = agent_def.get("role", None)
goal = agent_def.get("goal", None)
backstory = agent_def.get("backstory", None)
# G1: Resolve tools from YAML config
# G6: Fall back to system_prompt if instructions is empty
if not instructions:
instructions = agent_def.get("system_prompt", "") or ""
# Resolve tools from YAML config
agent_tools = []
yaml_tool_names = agent_def.get("tools", [])
if yaml_tool_names and tool_resolver:
@@ -948,16 +978,32 @@ class WebSocketGateway:
logger.debug(f"Resolved tool '{tool_name}' for agent '{agent_id}'")
else:
logger.warning(f"Tool '{tool_name}' not found for agent '{agent_id}'")
# Additional agent options from YAML
tool_choice = agent_def.get("tool_choice", None)
reflection = agent_def.get("reflection", True) # Default: enable reflection/interactive mode
reflection = agent_def.get("reflection", True)
allow_delegation = agent_def.get("allow_delegation", False)
# G5: Wire guardrails if config has a matching agent_name or global rule
guardrail_prompt = None
if guardrails_cfg:
for _gr_id, gr_def in guardrails_cfg.items():
gr_agent = gr_def.get("agent_name", "")
if not gr_agent or gr_agent == agent_id:
guardrail_prompt = gr_def.get("description", None)
break # first match wins
# Compose model as dict when temperature is specified
# (Agent accepts temperature via dict model config)
model_cfg = model
if temperature is not None and model and isinstance(model, str):
model_cfg = {"model": model, "temperature": temperature}
# Use model= (preferred) instead of deprecated llm=
agent = Agent(
name=agent_id,
instructions=instructions,
llm=model,
model=model_cfg,
memory=memory,
tools=agent_tools if agent_tools else None,
reflection=reflection,
@@ -965,14 +1011,21 @@ class WebSocketGateway:
role=role,
goal=goal,
backstory=backstory,
base_url=base_url,
api_key=api_key,
guardrails=guardrail_prompt,
)
# Store tool_choice for later use in chat()
if tool_choice:
agent._yaml_tool_choice = tool_choice
self.register_agent(agent, agent_id=agent_id)
logger.info(f"Created agent '{agent_id}' (model={model}, tools={len(agent_tools)}, reflection={reflection})")
logger.info(
f"Created agent '{agent_id}' "
f"(model={model}, tools={len(agent_tools)}, "
f"reflection={reflection})"
)
def _determine_routing_context(
self, channel_type: str, message_metadata: Dict[str, Any]
@@ -1022,12 +1075,14 @@ class WebSocketGateway:
from praisonaiagents.bots import BotConfig
for channel_name, ch_cfg in channels_cfg.items():
channel_type = channel_name.lower()
channel_type = ch_cfg.get("platform", channel_name).lower()
token = ch_cfg.get("token", "")
# WhatsApp web mode doesn't require a token
wa_web_mode = (channel_type == "whatsapp" and
ch_cfg.get("mode", "cloud").lower().strip() == "web")
if not token and not wa_web_mode:
# Email/AgentMail use env vars for tokens — not required in YAML
is_email_platform = channel_type in ("email", "agentmail")
if not token and not wa_web_mode and not is_email_platform:
logger.warning(f"No token for channel '{channel_name}', skipping")
continue
@@ -1092,6 +1147,25 @@ class WebSocketGateway:
mode=wa_mode,
creds_dir=ch_cfg.get("creds_dir"),
)
elif channel_type == "email":
from praisonai.bots import EmailBot
email_token = token or os.environ.get("EMAIL_APP_PASSWORD", "")
return EmailBot(
token=email_token,
agent=agent,
email_address=ch_cfg.get("email_address") or os.environ.get("EMAIL_ADDRESS", ""),
imap_server=ch_cfg.get("imap_server") or os.environ.get("EMAIL_IMAP_SERVER", ""),
smtp_server=ch_cfg.get("smtp_server") or os.environ.get("EMAIL_SMTP_SERVER", ""),
)
elif channel_type == "agentmail":
from praisonai.bots import AgentMailBot
am_token = token or os.environ.get("AGENTMAIL_API_KEY", "")
return AgentMailBot(
token=am_token,
agent=agent,
inbox_id=ch_cfg.get("inbox_id") or os.environ.get("AGENTMAIL_INBOX_ID", ""),
domain=ch_cfg.get("domain") or os.environ.get("AGENTMAIL_DOMAIN", ""),
)
else:
logger.warning(f"Unknown channel type: {channel_type}")
return None
@@ -1175,6 +1249,7 @@ class WebSocketGateway:
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
)
@@ -1216,6 +1291,8 @@ class WebSocketGateway:
if not message_text:
return
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
# Determine routing context
chat_type = update.message.chat.type if update.message.chat else "private"
routing_ctx = gateway._determine_routing_context(
@@ -1229,9 +1306,33 @@ class WebSocketGateway:
if bot.config.typing_indicator:
await update.message.chat.send_action("typing")
# Ack reaction — show processing indicator
ack_ctx = None
if bot._ack.enabled:
async def _tg_react(emoji, **kw):
try:
from telegram import ReactionTypeEmoji
await app.bot.set_message_reaction(
chat_id=update.message.chat_id,
message_id=update.message.message_id,
reaction=[ReactionTypeEmoji(emoji=emoji)],
)
except Exception:
pass
async def _tg_unreact(emoji, **kw):
try:
await app.bot.set_message_reaction(
chat_id=update.message.chat_id,
message_id=update.message.message_id,
reaction=[],
)
except Exception:
pass
ack_ctx = await bot._ack.ack(react_fn=_tg_react)
try:
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, message_text)
message_text = await bot._debouncer.debounce(user_id, message_text)
response = await bot._session.chat(agent, user_id, message_text)
if hasattr(bot, '_send_response_with_media'):
await bot._send_response_with_media(
update.message.chat_id,
@@ -1240,6 +1341,9 @@ class WebSocketGateway:
)
else:
await update.message.reply_text(str(response))
# Done reaction
if ack_ctx:
await bot._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
except Exception as e:
logger.error(f"Agent error in {name}: {e}")
await update.message.reply_text(f"Error: {str(e)}")
@@ -1247,7 +1351,27 @@ class WebSocketGateway:
async def handle_voice(update: Update, context: Any):
await handle_message(update, context)
async def handle_status(update: Update, context: Any):
if not update.message:
return
await update.message.reply_text(bot._format_status())
async def handle_new(update: Update, context: Any):
if not update.message:
return
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
bot._session.reset(user_id)
await update.message.reply_text("Session reset. Starting fresh conversation.")
async def handle_help(update: Update, context: Any):
if not update.message:
return
await update.message.reply_text(bot._format_help())
# Register handlers
app.add_handler(CommandHandler("status", handle_status))
app.add_handler(CommandHandler("new", handle_new))
app.add_handler(CommandHandler("help", handle_help))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
@@ -1310,9 +1434,16 @@ class WebSocketGateway:
# Recreate agents
agents_cfg = cfg.get("agents", {})
provider_cfg = cfg.get("provider", {})
default_model = provider_cfg.get("model") if provider_cfg else None
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
if agents_cfg:
self._agents.clear()
self._create_agents_from_config(agents_cfg)
self._create_agents_from_config(
agents_cfg,
default_model=default_model,
guardrails_cfg=guardrails_cfg,
)
# Restart channels
channels_cfg = cfg.get("channels", {})
@@ -1361,8 +1492,15 @@ class WebSocketGateway:
# Create agents
agents_cfg = cfg.get("agents", {})
provider_cfg = cfg.get("provider", {})
default_model = provider_cfg.get("model") if provider_cfg else None
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
if agents_cfg:
self._create_agents_from_config(agents_cfg)
self._create_agents_from_config(
agents_cfg,
default_model=default_model,
guardrails_cfg=guardrails_cfg,
)
# Start channels + WebSocket server concurrently
channels_cfg = cfg.get("channels", {})
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="23" cy="23" r="23" fill="white"/>
<path d="M32.73 7h-6.945L38.45 39h6.945L32.73 7ZM12.665 7 0 39h7.082l2.59-6.72h13.25l2.59 6.72h7.082L19.929 7h-7.264Zm-.702 19.337 4.334-11.246 4.334 11.246h-8.668Z" fill="#000000"></path>
</svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 600 450" style="enable-background:new 0 0 600 450;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#343B45;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#F4981A;}
</style>
<g id="_x31__stroke">
<g id="Amazon_1_">
<rect x="161.2" y="86.5" class="st0" width="277.8" height="277.8"/>
<g id="Amazon">
<path class="st1" d="M315,163.7c-8,0.6-17.2,1.2-26.4,2.4c-14.1,1.9-28.2,4.3-39.8,9.8c-22.7,9.2-38,28.8-38,57.6
c0,36.2,23.3,54.6,52.7,54.6c9.8,0,17.8-1.2,25.1-3.1c11.7-3.7,21.5-10.4,33.1-22.7c6.7,9.2,8.6,13.5,20.2,23.3
c3.1,1.2,6.1,1.2,8.6-0.6c7.4-6.1,20.3-17.2,27-23.3c3.1-2.5,2.5-6.1,0.6-9.2c-6.7-8.6-13.5-16-13.5-32.5V165
c0-23.3,1.9-44.8-15.3-60.7c-14.1-12.9-36.2-17.8-53.4-17.8h-7.4c-31.2,1.8-64.3,15.3-71.7,54c-1.2,4.9,2.5,6.8,4.9,7.4l34.3,4.3
c3.7-0.6,5.5-3.7,6.1-6.7c3.1-13.5,14.1-20.2,26.3-21.5h2.5c7.4,0,15.3,3.1,19.6,9.2c4.9,7.4,4.3,17.2,4.3,25.8L315,163.7
L315,163.7z M308.2,236.7c-4.3,8.6-11.7,14.1-19.6,16c-1.2,0-3.1,0.6-4.9,0.6c-13.5,0-21.4-10.4-21.4-25.8
c0-19.6,11.6-28.8,26.3-33.1c8-1.8,17.2-2.5,26.4-2.5v7.4C315,213.4,315.6,224.4,308.2,236.7z"/>
<path class="st2" d="M398.8,311.4c-1.4,0-2.8,0.3-4.1,0.9c-1.5,0.6-3,1.3-4.4,1.9l-2.1,0.9l-2.7,1.1v0
c-29.8,12.1-61.1,19.2-90.1,19.8c-1.1,0-2.1,0-3.2,0c-45.6,0-82.8-21.1-120.3-42c-1.3-0.7-2.7-1-4-1c-1.7,0-3.4,0.6-4.7,1.8
c-1.3,1.2-2,2.9-2,4.7c0,2.3,1.2,4.4,2.9,5.7c35.2,30.6,73.8,59,125.7,59c1,0,2,0,3.1,0c33-0.7,70.3-11.9,99.3-30.1l0.2-0.1
c3.8-2.3,7.6-4.9,11.2-7.7c2.2-1.6,3.8-4.2,3.8-6.9C407.2,314.6,403.2,311.4,398.8,311.4z M439,294.5L439,294.5
c-0.1-2.9-0.7-5.1-1.9-6.9l-0.1-0.2l-0.1-0.2c-1.2-1.3-2.4-1.8-3.7-2.4c-3.8-1.5-9.3-2.3-16-2.3c-4.8,0-10.1,0.5-15.4,1.6l0-0.4
l-5.3,1.8l-0.1,0l-3,1v0.1c-3.5,1.5-6.8,3.3-9.8,5.5c-1.9,1.4-3.4,3.2-3.5,6.1c0,1.5,0.7,3.3,2,4.3c1.3,1,2.8,1.4,4.1,1.4
c0.3,0,0.6,0,0.9-0.1l0.3,0l0.2,0c2.6-0.6,6.4-0.9,10.9-1.6c3.8-0.4,7.9-0.7,11.4-0.7c2.5,0,4.7,0.2,6.3,0.5
c0.8,0.2,1.3,0.4,1.6,0.5c0.1,0,0.2,0.1,0.2,0.1c0.1,0.2,0.2,0.8,0.1,1.5c0,2.9-1.2,8.4-2.9,13.7c-1.7,5.3-3.7,10.7-5,14.2
c-0.3,0.8-0.5,1.7-0.5,2.7c0,1.4,0.6,3.2,1.8,4.3c1.2,1.1,2.8,1.6,4.1,1.6h0.1c2,0,3.6-0.8,5.1-1.9
c13.6-12.2,18.3-31.7,18.5-42.6L439,294.5z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="52.91666mm"
height="15.244921mm"
viewBox="0 0 52.91666 15.244921"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="Azurey.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.5246067"
inkscape:cx="67.4659"
inkscape:cy="38.024323"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:pagecheckerboard="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="930"
inkscape:window-height="781"
inkscape:window-x="667"
inkscape:window-y="117"
inkscape:window-maximized="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(677.9313,-313.85407)">
<g
transform="matrix(0.03978217,0,0,0.03978217,-658.51488,317.36166)"
id="layer1-1"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path21"
d="m -259.18627,274.57516 c 63.5635,-11.22863 116.06332,-20.52032 116.66629,-20.64819 l 1.09632,-0.2325 -60.01021,-71.38026 c -33.00559,-39.25915 -60.01018,-71.53249 -60.01018,-71.71853 0,-0.35223 61.96561,-170.992127 62.31396,-171.599207 0.11647,-0.20297 42.28595,72.60148 102.221586,176.482797 56.11284,97.25555 102.3751556,177.4431 102.8051496,178.19452 l 0.78184,1.36624 -190.7173956,-0.0246 -190.71736,-0.0245 115.57,-20.4157 z m 731.37889,-17.26291 c -29.03093,-1.86128 -45.91544,-18.39214 -50.38974,-49.33422 -1.19021,-8.2311 -1.19476,-8.44466 -1.31656,-61.88078 l -0.11789,-51.717227 h 12.84834 12.8483 l 0.10118,50.023887 c 0.0912,45.08514 0.14842,50.37131 0.57946,53.54292 1.74935,12.87168 5.23201,21.5276 11.16124,27.74067 4.74528,4.97242 10.30038,7.8839 17.99011,9.42882 3.62783,0.72884 13.94813,0.7303 17.25569,0.003 7.79588,-1.7156 14.0442,-5.10811 19.55617,-10.61799 6.28249,-6.28012 10.93015,-15.1903 13.17899,-25.26595 l 0.75793,-3.39586 0.0844,-50.44723 0.0844,-50.447217 h 13.11732 13.11735 v 79.304447 79.30444 h -12.98222 -12.98222 v -12.5824 c 0,-8.54933 -0.0937,-12.55118 -0.29222,-12.485 -0.16069,0.0536 -0.82744,1.07357 -1.48166,2.26667 -4.47373,8.15896 -11.92745,15.62029 -20.09501,20.1155 -9.79024,5.38827 -19.60858,7.30422 -33.02333,6.44412 z m 294.66636,-0.12816 c -10.24795,-0.7703 -21.03846,-4.29359 -29.85796,-9.74908 -18.58873,-11.49853 -29.58761,-30.45184 -32.80805,-56.53499 -1.11441,-9.02575 -1.24835,-21.14964 -0.3184,-28.81907 2.07786,-17.13682 8.81185,-33.95976 18.40106,-45.96982 2.45824,-3.07883 8.03495,-8.65553 11.11365,-11.11361 8.31379,-6.637877 18.03843,-11.279617 28.36334,-13.538327 6.01579,-1.31603 16.60968,-1.93394 23.00111,-1.34159 16.05576,1.48805 30.77105,9.04765 40.77267,20.945887 10.1615,12.08847 15.74681,28.98006 16.46007,49.77987 0.11179,3.25966 0.13889,8.97466 0.0603,12.7 l -0.143,6.77333 -56.23278,0.0712 -56.23278,0.0712 v 2.5094 c 0,7.63915 1.85722,16.33991 5.06795,23.74265 2.76877,6.38373 7.53485,13.35462 11.43714,16.72802 8.00131,6.91687 17.79386,11.05701 28.50936,12.05334 3.97318,0.3694 14.09889,-0.0195 18.62666,-0.7152 12.91802,-1.98524 25.19946,-7.052 35.11745,-14.48783 1.16688,-0.87486 2.28357,-1.69223 2.48152,-1.81635 0.28968,-0.18164 0.35814,2.10408 0.35081,11.71222 l -0.009,11.93792 -2.65743,1.64355 c -11.21971,6.93911 -24.07305,11.39481 -37.68211,13.06277 -4.06135,0.49775 -18.96443,0.71949 -23.82142,0.35441 z m 48.93919,-100.68528 c 0,-12.79609 -5.39245,-27.01096 -13.02337,-34.3305 -5.44587,-5.22367 -12.02921,-8.41603 -19.85552,-9.62822 -3.70264,-0.5735 -11.50631,-0.35262 -15.41004,0.43617 -8.25234,1.66748 -15.07811,5.29536 -21.03154,11.17821 -6.26561,6.19133 -10.96323,13.71122 -13.91756,22.27909 -1.06234,3.08091 -2.30488,8.13901 -2.69056,10.95257 l -0.18376,1.34056 h 43.05616 43.05619 z M 53.010852,253.20058 c 0.06587,-0.19403 19.161194,-50.3586 42.434086,-111.47682 l 42.314342,-111.124037 13.59583,-8.5e-4 13.5958,-8.4e-4 1.12805,2.89278 c 3.4472,8.84008 84.71032,219.821587 84.71032,219.931197 0,0.0722 -6.50875,0.13039 -14.46389,0.12923 l -14.46389,-0.003 -11.71222,-31.18282 -11.71222,-31.18281 -47.15885,-5.7e-4 -47.15886,-5.6e-4 -0.40982,1.05833 c -0.22538,0.58208 -5.229376,14.61335 -11.120031,31.18062 l -10.710221,30.12225 -14.494087,0.005 c -11.4727,0.004 -14.469109,-0.0686 -14.374339,-0.34784 z M 189.82928,167.38571 c 0,-0.0494 -7.88187,-21.41719 -17.51527,-47.48389 -18.02243,-48.766297 -19.03061,-51.700447 -20.45454,-59.529577 -0.66771,-3.67122 -1.00556,-3.77324 -1.40188,-0.42333 -0.2833,2.39448 -1.51167,7.75245 -2.45866,10.72445 -0.46988,1.47461 -8.58577,23.74972 -18.03533,49.500237 -9.44954,25.75052 -17.18099,46.92777 -17.18099,47.06055 0,0.13279 17.33551,0.24143 38.52334,0.24143 21.18783,0 38.52333,-0.0404 38.52333,-0.0899 z m 69.70889,82.10639 v -4.06127 l 46.98461,-64.58929 46.98461,-64.5893 -42.53961,-0.14111 -42.53961,-0.14111 -0.0739,-10.795 -0.0739,-10.794997 h 61.52781 61.5278 v 3.64606 3.646057 l -46.99,64.94153 c -25.8445,35.71785 -46.99,65.00162 -46.99,65.07506 0,0.0734 20.8915,0.13352 46.42555,0.13352 h 46.42556 v 10.86555 10.86556 h -65.33447 -65.33445 z m 344.78149,3.8731 c -0.10349,-0.10346 -0.18816,-35.91746 -0.18816,-79.58666 V 94.380023 h 12.84112 12.84111 v 16.368887 c 0,9.00289 0.10947,16.36889 0.2433,16.36889 0.13383,0 0.75881,-1.47368 1.38884,-3.27484 2.88711,-8.2536 7.89393,-15.96572 14.34443,-22.09509 5.81095,-5.521657 12.45235,-8.823637 20.28898,-10.087287 2.20134,-0.35497 4.064,-0.42274 8.46667,-0.30806 5.52924,0.14402 8.42001,0.54819 11.78278,1.64738 l 1.05833,0.34594 v 13.328937 13.32893 l -3.03389,-1.51868 c -5.3592,-2.68267 -10.64714,-3.74429 -17.00389,-3.41375 -4.13707,0.21513 -6.85252,0.74636 -10.16,1.98766 -6.80519,2.55399 -12.32518,7.06198 -16.18228,13.2155 -5.58374,8.90817 -9.56612,20.35873 -10.7407,30.88287 -0.21079,1.88864 -0.33508,17.27019 -0.38459,47.59327 l -0.0731,44.80278 H 617.1582 c -6.95771,0 -12.73506,-0.0847 -12.83852,-0.18816 z M -488.0685,252.80762 c 0,-0.1005 28.27652,-49.18632 62.83671,-109.07964 Line truncated
style="fill:#0089d6;fill-opacity:1;stroke-width:0.28222221" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800 600" style="enable-background:new 0 0 800 600;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#F05A28;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#231F20;}
</style>
<g id="Contact">
<g id="Contact-us" transform="translate(-234.000000, -1114.000000)">
<g id="map" transform="translate(-6.000000, 1027.000000)">
<g id="Contact-box" transform="translate(190.000000, 36.000000)">
<g id="Group-26" transform="translate(50.000000, 51.000000)">
<g id="Group-3">
<path id="Fill-1" class="st0" d="M220.9,421c-17,0-33.1-3.4-47.8-9.5c-22-9.2-40.8-24.6-54.1-44c-13.3-19.4-21-42.7-21-67.9
c0-16.8,3.4-32.7,9.7-47.3c9.3-21.8,24.9-40.3,44.5-53.4c19.6-13.1,43.2-20.7,68.7-20.7v-18.3c-19.5,0-38.1,3.9-55.1,11
c-25.4,10.6-47,28.3-62.2,50.6c-15.3,22.3-24.2,49.2-24.2,78.1c0,19.3,4,37.7,11.1,54.4c10.7,25.1,28.7,46.4,51.2,61.5
c22.6,15.1,49.8,23.9,79.1,23.9V421z"/>
<path id="Fill-4" class="st0" d="M157.9,374.1c-11.5-9.6-20.1-21.2-25.9-33.9c-5.8-12.7-8.8-26.4-8.8-40.2
c0-11,1.9-22,5.6-32.5c3.8-10.5,9.4-20.5,17.1-29.6c9.6-11.4,21.3-20,34-25.8c12.7-5.8,26.6-8.7,40.4-8.7
c11,0,22.1,1.9,32.6,5.6c10.6,3.8,20.6,9.4,29.7,17l11.9-14.1c-10.8-9-22.8-15.8-35.4-20.2c-12.6-4.5-25.7-6.7-38.8-6.7
c-16.5,0-32.9,3.5-48.1,10.4c-15.2,6.9-29.1,17.2-40.5,30.7c-9.1,10.8-15.8,22.7-20.3,35.2c-4.5,12.5-6.7,25.6-6.7,38.7
c0,16.4,3.5,32.8,10.4,47.9c6.9,15.1,17.3,29,30.9,40.3L157.9,374.1z"/>
<path id="Fill-6" class="st0" d="M186.4,362.2c-12.1-6.4-21.6-15.7-28.1-26.6c-6.5-10.9-9.9-23.5-9.9-36.2
c0-11.2,2.6-22.5,8.3-33c6.4-12.1,15.8-21.5,26.8-27.9c11-6.5,23.6-9.9,36.4-9.9c11.2,0,22.6,2.6,33.2,8.2l8.6-16.3
c-13.3-7-27.7-10.4-41.9-10.3c-16.1,0-32,4.3-45.8,12.4c-13.8,8.1-25.7,20.1-33.7,35.2c-7,13.3-10.4,27.6-10.4,41.6
c0,16,4.3,31.8,12.5,45.5c8.2,13.8,20.2,25.5,35.4,33.5L186.4,362.2z"/>
<path id="Fill-8" class="st0" d="M221,344.6c-6.3,0-12.3-1.3-17.7-3.6c-8.2-3.4-15.1-9.2-20-16.5c-4.9-7.3-7.8-16-7.8-25.4
c0-6.3,1.3-12.3,3.6-17.7c3.4-8.1,9.2-15.1,16.5-20c7.3-4.9,16-7.8,25.4-7.8v-18.4c-8.8,0-17.2,1.8-24.9,5
c-11.5,4.9-21.2,12.9-28.1,23.1C161,273.6,157,286,157,299.2c0,8.8,1.8,17.2,5,24.9c4.9,11.5,13,21.2,23.2,28.1
C195.4,359,207.7,363,221,363V344.6z"/>
</g>
<g id="Group" transform="translate(22.000000, 13.000000)">
<path id="Fill-10" class="st1" d="M214,271.6c-2.1-2.2-4.4-4-6.7-5.3c-2.3-1.3-4.7-2-7.2-2c-3.4,0-6.3,0.6-9,1.8
c-2.6,1.2-4.9,2.8-6.8,4.9c-1.9,2-3.3,4.4-4.3,7c-1,2.6-1.4,5.4-1.4,8.2c0,2.8,0.5,5.6,1.4,8.2c1,2.6,2.4,5,4.3,7
c1.9,2,4.1,3.7,6.8,4.9c2.6,1.2,5.6,1.8,9,1.8c2.8,0,5.5-0.6,7.9-1.7c2.4-1.2,4.5-2.9,6.2-5.1l12.2,13.1
c-1.8,1.8-3.9,3.4-6.3,4.7c-2.4,1.3-4.8,2.4-7.2,3.2s-4.8,1.4-7,1.7c-2.2,0.4-4.2,0.5-5.8,0.5c-5.5,0-10.7-0.9-15.5-2.7
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c4.7,0,9.4,0.9,14.1,2.7
c4.7,1.8,8.9,4.6,12.4,8.4L214,271.6z"/>
<path id="Fill-12" class="st1" d="M280.4,278.9c-0.1-5.4-1.8-9.6-5-12.7c-3.3-3.1-7.8-4.6-13.6-4.6c-5.5,0-9.8,1.6-13,4.7
c-3.2,3.1-5.2,7.4-5.9,12.6H280.4z M243,292.6c0.6,5.5,2.7,9.7,6.4,12.8c3.7,3,8.1,4.6,13.3,4.6c4.6,0,8.4-0.9,11.5-2.8
c3.1-1.9,5.8-4.2,8.2-7.1l13.1,9.9c-4.3,5.3-9,9-14.3,11.3c-5.3,2.2-10.8,3.3-16.6,3.3c-5.5,0-10.7-0.9-15.5-2.7
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c5.1,0,9.7,0.9,13.9,2.7
c4.2,1.8,7.8,4.3,10.8,7.7c3,3.3,5.3,7.5,7,12.4c1.7,4.9,2.5,10.6,2.5,17v5H243z"/>
<path id="Fill-14" class="st1" d="M306.5,249.7h18.3v11.5h0.3c2-4.3,4.9-7.5,8.7-9.9c3.8-2.3,8.1-3.5,12.9-3.5
c1.1,0,2.2,0.1,3.3,0.3c1.1,0.2,2.2,0.5,3.3,0.8v17.6c-1.5-0.4-3-0.7-4.5-1c-1.5-0.3-2.9-0.4-4.3-0.4c-4.3,0-7.7,0.8-10.3,2.4
c-2.6,1.6-4.6,3.4-5.9,5.4c-1.4,2-2.3,4.1-2.7,6.1c-0.5,2-0.7,3.5-0.7,4.6v39h-18.3V249.7z"/>
<path id="Fill-16" class="st1" d="M409,278.9c-0.1-5.4-1.8-9.6-5-12.7c-3.3-3.1-7.8-4.6-13.6-4.6c-5.5,0-9.8,1.6-13,4.7
c-3.2,3.1-5.2,7.4-5.9,12.6H409z M371.6,292.6c0.6,5.5,2.7,9.7,6.4,12.8c3.7,3,8.1,4.6,13.3,4.6c4.6,0,8.4-0.9,11.5-2.8
c3.1-1.9,5.8-4.2,8.2-7.1l13.1,9.9c-4.3,5.3-9,9-14.3,11.3c-5.3,2.2-10.8,3.3-16.6,3.3c-5.5,0-10.7-0.9-15.5-2.7
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c5.1,0,9.7,0.9,13.9,2.7
c4.2,1.8,7.8,4.3,10.8,7.7c3,3.3,5.3,7.5,7,12.4c1.7,4.9,2.5,10.6,2.5,17v5H371.6z"/>
<path id="Fill-18" class="st1" d="M494.6,286.2c0-2.8-0.5-5.6-1.5-8.2c-1-2.6-2.4-5-4.3-7c-1.9-2-4.2-3.7-6.9-4.9
c-2.7-1.2-5.7-1.8-9.1-1.8c-3.4,0-6.4,0.6-9.1,1.8c-2.7,1.2-5,2.8-6.9,4.9c-1.9,2-3.3,4.4-4.3,7c-1,2.6-1.5,5.4-1.5,8.2
c0,2.8,0.5,5.6,1.5,8.2c1,2.6,2.4,5,4.3,7c1.9,2,4.2,3.7,6.9,4.9c2.7,1.2,5.7,1.8,9.1,1.8c3.4,0,6.4-0.6,9.1-1.8
c2.7-1.2,5-2.8,6.9-4.9c1.9-2,3.3-4.4,4.3-7C494.1,291.8,494.6,289,494.6,286.2L494.6,286.2z M433.2,207.6h18.5v51.3h0.5
c0.9-1.2,2.1-2.5,3.5-3.7c1.4-1.3,3.2-2.5,5.2-3.6c2.1-1.1,4.4-2,7.1-2.7c2.7-0.7,5.8-1.1,9.3-1.1c5.2,0,10.1,1,14.5,3
c4.4,2,8.2,4.7,11.3,8.1c3.1,3.5,5.6,7.5,7.3,12.2c1.7,4.7,2.6,9.7,2.6,15.1c0,5.4-0.8,10.4-2.5,15.1
c-1.6,4.7-4.1,8.7-7.2,12.2c-3.2,3.5-7,6.2-11.6,8.1c-4.5,2-9.6,3-15.3,3c-5.2,0-10.1-1-14.7-3c-4.5-2-8.1-5.3-10.8-9.7h-0.3
v11h-17.6V207.6z"/>
<path id="Fill-20" class="st1" d="M520.9,249.7h18.3v11.5h0.3c2-4.3,4.9-7.5,8.7-9.9c3.8-2.3,8.1-3.5,12.9-3.5
c1.1,0,2.2,0.1,3.3,0.3c1.1,0.2,2.2,0.5,3.3,0.8v17.6c-1.5-0.4-3-0.7-4.5-1c-1.5-0.3-2.9-0.4-4.3-0.4c-4.3,0-7.7,0.8-10.3,2.4
c-2.6,1.6-4.6,3.4-5.9,5.4c-1.4,2-2.3,4.1-2.7,6.1c-0.5,2-0.7,3.5-0.7,4.6v39h-18.3V249.7z"/>
<path id="Fill-22" class="st1" d="M616,290h-3.9c-2.6,0-5.5,0.1-8.7,0.3c-3.2,0.2-6.2,0.7-9.1,1.4c-2.8,0.8-5.2,1.9-7.2,3.3
c-2,1.5-2.9,3.5-2.9,6.2c0,1.7,0.4,3.2,1.2,4.3c0.8,1.2,1.8,2.2,3,3c1.2,0.8,2.6,1.4,4.2,1.8c1.5,0.4,3.1,0.5,4.6,0.5
c6.4,0,11.1-1.5,14.2-4.5c3-3,4.6-7.1,4.6-12.2V290z M617.1,312.7h-0.5c-2.7,4.2-6.1,7.2-10.2,9.1c-4.1,1.9-8.7,2.8-13.6,2.8
c-3.4,0-6.7-0.5-10-1.4s-6.1-2.3-8.7-4.1c-2.5-1.8-4.6-4.1-6.1-6.8s-2.3-5.9-2.3-9.6c0-4,0.7-7.3,2.2-10.1
c1.4-2.8,3.4-5.1,5.8-7c2.4-1.9,5.2-3.4,8.4-4.5c3.2-1.1,6.5-2,10-2.5c3.5-0.6,6.9-0.9,10.5-1.1c3.5-0.2,6.8-0.2,9.9-0.2h4.6
v-2c0-4.6-1.6-8-4.8-10.3c-3.2-2.3-7.3-3.4-12.2-3.4c-3.9,0-7.6,0.7-11,2.1c-3.4,1.4-6.4,3.2-8.8,5.6l-9.8-9.6
c4.1-4.2,9-7.1,14.5-9c5.5-1.8,11.2-2.7,17.1-2.7c5.3,0,9.7,0.6,13.3,1.7c3.6,1.2,6.6,2.7,9,4.5c2.4,1.8,4.2,3.9,5.5,6.3
c1.3,2.4,2.2,4.8,2.8,7.2c0.6,2.4,0.9,4.8,1,7.1c0.1,2.3,0.2,4.3,0.2,6v42h-16.7V312.7z"/>
<path id="Fill-24" class="st1" d="M683.6,269.9c-3.6-5-8.4-7.5-14.4-7.5c-2.5,0-4.9,0.6-7.2,1.8c-2.4,1.2-3.5,3.2-3.5,5.9
c0,2.2,1,3.9,2.9,4.9c1.9,1,4.4,1.9,7.4,2.6c3,0.7,6.2,1.4,9.6,2.2c3.4,0.8,6.6,1.9,9.6,3.5c3,1.6,5.4,3.7,7.4,6.5
c1.9,2.7,2.9,6.5,2.9,11.3c0,4.4-0.9,8-2.8,11c-1.9,3-4.3,5.4-7.4,7.2c-3,1.8-6.4,3.1-10.2,4c-3.8,0.8-7.6,1.2-11.3,1.2
c-5.7,0-11-0.8-15.8-2.4c-4.8-1.6-9.1-4.6-12.9-8.8l12.3-11.4c2.4,2.6,4.9,4.8,7.6,6.5c2.7,1.7,6,2.5,9.9,2.5
c1.3,0,2.7-0.2,4.1-0.5c1.4-0.3,2.8-0.8,4-1.5c1.2-0.7,2.2-1.6,3-2.7c0.8-1.1,1.1-2.3,1.1-3.7c0-2.5-1-4.4-2.9-5.6
c-1.9-1.2-4.4-2.2-7.4-3c-3-0.8-6.2-1.5-9.6-2.1c-3.4-0.7-6.6-1.7-9.6-3.2c-3-1.5-5.4-3.5-7.4-6.2c-1.9-2.6-2.9-6.3-2.9-11
c0-4.1,0.8-7.6,2.5-10.6c1.7-3,3.9-5.4,6.7-7.4c2.8-1.9,5.9-3.3,9.5-4.3c3.6-0.9,7.2-1.4,10.9-1.4c4.9,0,9.8,0.8,14.6,2.5
c4.8,1.7,8.7,4.5,11.7,8.6L683.6,269.9z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 75 75" viewBox="0 0 75 75" width="75" height="75" ><path d="M24.3 44.7c2 0 6-.1 11.6-2.4 6.5-2.7 19.3-7.5 28.6-12.5 6.5-3.5 9.3-8.1 9.3-14.3C73.8 7 66.9 0 58.3 0h-36C10 0 0 10 0 22.3s9.4 22.4 24.3 22.4z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#39594d"/><path d="M30.4 60c0-6 3.6-11.5 9.2-13.8l11.3-4.7C62.4 36.8 75 45.2 75 57.6 75 67.2 67.2 75 57.6 75H45.3c-8.2 0-14.9-6.7-14.9-15z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#d18ee2"/><path d="M12.9 47.6C5.8 47.6 0 53.4 0 60.5v1.7C0 69.2 5.8 75 12.9 75c7.1 0 12.9-5.8 12.9-12.9v-1.7c-.1-7-5.8-12.8-12.9-12.8z" style="fill:#ff7759"/></svg>

After

Width:  |  Height:  |  Size: 742 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DBRX</title><path d="M21.821 9.894l-9.81 5.595L1.505 9.511 1 9.787v4.34l11.01 6.256 9.811-5.574v2.297l-9.81 5.596-10.506-5.979L1 17v.745L12.01 24 23 17.745v-4.34l-.505-.277-10.484 5.957-9.832-5.574v-2.298l9.832 5.574L23 10.532V6.255l-.547-.319-10.442 5.936-9.327-5.276 9.327-5.298 7.663 4.362.673-.383v-.532L12.011 0 1 6.255v.681l11.01 6.255 9.811-5.595z" fill="#EE3D2C" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 292.6 215.3" style="enable-background:new 0 0 292.6 215.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:#566AB2;}
</style>
<path class="st0" d="M191.3,123.7c-2.4,1-4.9,1.8-7.2,1.9c-3.6,0.2-7.6-1.3-9.7-3.1c-3.3-2.8-5.7-4.4-6.7-9.2
c-0.4-2.1-0.2-5.3,0.2-7.2c0.9-4-0.1-6.5-2.9-8.9c-2.3-1.9-5.2-2.4-8.4-2.4s-2.3-0.5-3.1-1c-1.3-0.7-2.4-2.3-1.4-4.4
c0.3-0.7,2-2.3,2.3-2.5c4.3-2.5,9.4-1.7,14,0.2c4.3,1.7,7.5,5,12.2,9.5c4.8,5.5,5.6,7,8.4,11.1c2.1,3.2,4.1,6.6,5.4,10.4
C195.2,120.5,194.2,122.4,191.3,123.7L191.3,123.7z M153.4,104.3c0-2.1,1.7-3.7,3.8-3.7s0.9,0.1,1.3,0.2c0.5,0.2,1,0.5,1.4,0.9
c0.7,0.7,1.1,1.6,1.1,2.6c0,2.1-1.7,3.8-3.8,3.8s-3.7-1.7-3.7-3.8H153.4z M141.2,182.8c-25.5-20-37.8-26.6-42.9-26.3
c-4.8,0.3-3.9,5.7-2.8,9.3c1.1,3.5,2.5,5.9,4.5,9c1.4,2,2.3,5.1-1.4,7.3c-8.2,5.1-22.5-1.7-23.1-2c-16.6-9.8-30.5-22.7-40.2-40.3
c-9.5-17-14.9-35.2-15.8-54.6c-0.2-4.7,1.1-6.4,5.8-7.2c6.2-1.1,12.5-1.4,18.7-0.5c26,3.8,48.1,15.4,66.7,33.8
c10.6,10.5,18.6,23,26.8,35.2c8.8,13,18.2,25.4,30.2,35.5c4.3,3.6,7.6,6.3,10.9,8.2c-9.8,1.1-26.1,1.3-37.2-7.5L141.2,182.8z
M289.5,18c-3.1-1.5-4.4,1.4-6.3,2.8c-0.6,0.5-1.1,1.1-1.7,1.7c-4.5,4.8-9.8,8-16.8,7.6c-10.1-0.6-18.7,2.6-26.4,10.4
c-1.6-9.5-7-15.2-15.2-18.9c-4.3-1.9-8.6-3.8-11.6-7.9c-2.1-2.9-2.7-6.2-3.7-9.4c-0.7-2-1.3-3.9-3.6-4.3c-2.4-0.4-3.4,1.7-4.3,3.4
c-3.8,7-5.3,14.6-5.2,22.4c0.3,17.5,7.7,31.5,22.4,41.4c1.7,1.1,2.1,2.3,1.6,3.9c-1,3.4-2.2,6.7-3.3,10.1c-0.7,2.2-1.7,2.7-4,1.7
c-8.1-3.4-15-8.4-21.2-14.4c-10.4-10.1-19.9-21.2-31.6-30c-2.8-2.1-5.5-4-8.4-5.7c-12-11.7,1.6-21.3,4.7-22.4
c3.3-1.2,1.2-5.3-9.5-5.2c-10.6,0-20.3,3.6-32.8,8.4c-1.8,0.7-3.7,1.2-5.7,1.7c-11.3-2.1-22.9-2.6-35.1-1.2
c-23,2.5-41.4,13.4-54.8,32C1,68.3-2.8,93.6,1.9,120c4.9,27.8,19.1,50.9,41,68.9c22.6,18.7,48.7,27.8,78.5,26.1
c18.1-1,38.2-3.5,60.9-22.7c5.7,2.8,11.7,4,21.7,4.8c7.7,0.7,15.1-0.4,20.8-1.5c9-1.9,8.4-10.2,5.1-11.7
c-26.3-12.3-20.5-7.3-25.7-11.3c13.3-15.8,33.5-32.2,41.3-85.4c0.6-4.2,0.1-6.9,0-10.3c0-2.1,0.4-2.9,2.8-3.1
c6.6-0.8,13-2.6,18.8-5.8c17-9.3,23.9-24.6,25.5-42.9c0.2-2.8,0-5.7-3-7.2L289.5,18z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Fireworks</title><path clip-rule="evenodd" d="M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z" fill="#5019C5" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 592 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#4285F4" d="M14.9 8.161c0-.476-.039-.954-.121-1.422h-6.64v2.695h3.802a3.24 3.24 0 01-1.407 2.127v1.75h2.269c1.332-1.22 2.097-3.02 2.097-5.15z"/><path fill="#34A853" d="M8.14 15c1.898 0 3.499-.62 4.665-1.69l-2.268-1.749c-.631.427-1.446.669-2.395.669-1.836 0-3.393-1.232-3.952-2.888H1.85v1.803A7.044 7.044 0 008.14 15z"/><path fill="#FBBC04" d="M4.187 9.342a4.17 4.17 0 010-2.68V4.859H1.849a6.97 6.97 0 000 6.286l2.338-1.803z"/><path fill="#EA4335" d="M8.14 3.77a3.837 3.837 0 012.7 1.05l2.01-1.999a6.786 6.786 0 00-4.71-1.82 7.042 7.042 0 00-6.29 3.858L4.186 6.66c.556-1.658 2.116-2.89 3.952-2.89z"/></svg>

After

Width:  |  Height:  |  Size: 728 B

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26.3 26.3"><defs><style>.cls-1{fill:#f05237;}.cls-2{fill:#fff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Content"><circle class="cls-1" cx="13.15" cy="13.15" r="13.15"/><path class="cls-2" d="M13.17,6.88a4.43,4.43,0,0,0,0,8.85h1.45V14.07H13.17a2.77,2.77,0,1,1,2.77-2.76v4.07a2.74,2.74,0,0,1-4.67,2L10.1,18.51a4.37,4.37,0,0,0,3.07,1.29h.06a4.42,4.42,0,0,0,4.36-4.4V11.2a4.43,4.43,0,0,0-4.42-4.32"/></g></g></svg>

After

Width:  |  Height:  |  Size: 619 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 490.16 411.7"><defs><style>.cls-1{fill:url(#未命名的渐变_6);}</style><linearGradient id="未命名的渐变_6" y1="205.85" x2="490.16" y2="205.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4177f"/><stop offset="0.5" stop-color="#e73562"/><stop offset="1" stop-color="#e94e4a"/></linearGradient></defs><title>资源 2</title><g id="图层_2" data-name="图层 2"><g id="图层_1-2" data-name="图层 1"><path class="cls-1" d="M233.45,40.81a17.55,17.55,0,1,0-35.1,0V331.56a40.82,40.82,0,0,1-81.63,0V145a17.55,17.55,0,1,0-35.09,0v79.06a40.82,40.82,0,0,1-81.63,0V195.42a11.63,11.63,0,0,1,23.26,0v28.66a17.55,17.55,0,0,0,35.1,0V145A40.82,40.82,0,0,1,140,145V331.56a17.55,17.55,0,0,0,35.1,0V217.5h0V40.81a40.81,40.81,0,1,1,81.62,0V281.56a11.63,11.63,0,1,1-23.26,0Zm215.9,63.4A40.86,40.86,0,0,0,408.53,145V300.85a17.55,17.55,0,0,1-35.09,0v-260a40.82,40.82,0,0,0-81.63,0V370.89a17.55,17.55,0,0,1-35.1,0V330a11.63,11.63,0,1,0-23.26,0v40.86a40.81,40.81,0,0,0,81.62,0V40.81a17.55,17.55,0,0,1,35.1,0v260a40.82,40.82,0,0,0,81.63,0V145a17.55,17.55,0,1,1,35.1,0V281.56a11.63,11.63,0,0,0,23.26,0V145A40.85,40.85,0,0,0,449.35,104.21Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more