Compare commits

...
30 Commits
Author SHA1 Message Date
Mervin Praison 34b6aeb76b Release v2.4.0 2025-12-24 07:47:53 +00:00
Mervin Praison 078b7801e1 Add 'response' field to workflow API for consistency with single-agent endpoint 2025-12-23 17:50:07 +00:00
Mervin Praison a970e849b4 Release v2.3.87 2025-12-23 16:08:35 +00:00
Mervin Praison 8e68f4e71c Release v2.3.86 2025-12-23 15:23:31 +00:00
Mervin Praison 308758cdc2 Release v2.3.85 2025-12-23 15:07:46 +00:00
Mervin Praison f8fa61b082 Release v2.3.84 2025-12-23 14:57:15 +00:00
Mervin Praison 955d236924 Release v2.3.84 2025-12-23 13:20:08 +00:00
Mervin Praison d9d63c3a13 Release v2.3.83 2025-12-23 13:06:42 +00:00
Mervin Praison 5bee6eb470 Release v2.3.83 2025-12-23 11:42:47 +00:00
Mervin Praison 7cb1676b7c Release v2.3.82 2025-12-23 09:41:09 +00:00
Mervin Praison e2e9c3f9ee Release v2.3.81 2025-12-23 08:11:31 +00:00
Mervin Praison cbe8beeca6 Release v2.3.80 2025-12-23 07:54:07 +00:00
Mervin Praison f7b66a52b7 Release v2.3.79 2025-12-23 07:27:35 +00:00
Mervin Praison 6dbf194312 Release v2.3.78 2025-12-23 06:46:15 +00:00
Mervin Praison 335e8d7e47 Release v2.3.77 2025-12-23 06:28:26 +00:00
Mervin Praison d65840e092 Release v2.3.76 2025-12-23 05:40:52 +00:00
Mervin Praison 5a13571fdd Release v2.3.75 2025-12-22 19:11:32 +00:00
Mervin Praison e3b48c2526 Release v2.3.74 2025-12-22 17:01:50 +00:00
Mervin Praison 8d32238b4c Release v2.3.73 2025-12-22 15:47:51 +00:00
Mervin Praison c17b1298bb Reorganize README feature table with improved categorization and hierarchy 2025-12-22 14:50:37 +00:00
Mervin Praison ec6c150315 Release v2.3.72 2025-12-22 13:44:48 +00:00
Mervin Praison 3d26cedd5d Release v2.3.71 2025-12-22 11:59:12 +00:00
Mervin Praison 8c735a60b2 Release v2.3.70 2025-12-22 10:43:48 +00:00
Mervin Praison 2f5f2f1c13 Release v2.3.69 2025-12-22 10:40:53 +00:00
Mervin Praison 0253073c68 Release v2.3.68 2025-12-22 10:35:45 +00:00
Mervin Praison 7f0e334e8c Release v2.3.67 2025-12-22 09:14:33 +00:00
Mervin Praison cacf1097ef Release v2.3.66 2025-12-22 08:50:02 +00:00
Mervin Praison 09f01919d2 Fix duplicate --max-retries argument conflict in CLI parser 2025-12-21 05:19:08 +00:00
Mervin Praison 92397615f8 Release v2.3.65 2025-12-20 21:49:04 +00:00
Mervin Praison 4b21943a53 Release v2.3.64 2025-12-20 19:57:39 +00:00
164 changed files with 29278 additions and 682 deletions

No files matched your search

+292 -195
View File
@@ -171,12 +171,13 @@ npm install praisonai
| Feature | Code | Docs |
|---------|:----:|:----:|
| 🚀 Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://docs.praison.ai/agents/single) |
| 🤝 Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://docs.praison.ai/concepts/agents) |
| 🤖 Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://docs.praison.ai/features/autoagents) |
| 🔄 Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://docs.praison.ai/features/selfreflection) |
| 🧠 Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://docs.praison.ai/features/reasoning) |
| 👁️ Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://docs.praison.ai/features/multimodal) |
| **🤖 Core Agents** | | |
| ↳ Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://docs.praison.ai/agents/single) |
| ↳ Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://docs.praison.ai/concepts/agents) |
| ↳ Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://docs.praison.ai/features/autoagents) |
| ↳ Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://docs.praison.ai/features/selfreflection) |
| ↳ Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://docs.praison.ai/features/reasoning) |
| ↳ Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://docs.praison.ai/features/multimodal) |
| **🔄 Workflows** | | |
| ↳ Simple Workflow | [Example](examples/python/workflows/simple_workflow.py) | [📖](https://docs.praison.ai/features/workflows) |
| ↳ Workflow with Agents | [Example](examples/python/workflows/workflow_with_agents.py) | [📖](https://docs.praison.ai/features/workflows) |
@@ -188,10 +189,7 @@ npm install praisonai
| ↳ Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/features/workflows) |
| ↳ Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/features/workflows) |
| ↳ Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/features/workflows) |
| 📚 Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
| 🧠 Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
| 📄 Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
| **💻 Code** | | |
| **💻 Code & Development** | | |
| ↳ Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
| ↳ AI Code Editing Tools | [Example](examples/python/code/code_editing_example.py) | [📖](https://docs.praison.ai/code/editing) |
| ↳ External Agents (All) | [Example](examples/python/code/external_agents_example.py) | [📖](https://docs.praison.ai/code/external-agents) |
@@ -199,65 +197,78 @@ npm install praisonai
| ↳ Gemini CLI | [Example](examples/python/code/gemini_cli_example.py) | [📖](https://docs.praison.ai/code/gemini-cli) |
| ↳ Codex CLI | [Example](examples/python/code/codex_cli_example.py) | [📖](https://docs.praison.ai/code/codex-cli) |
| ↳ Cursor CLI | [Example](examples/python/code/cursor_cli_example.py) | [📖](https://docs.praison.ai/code/cursor-cli) |
| 📚 RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
| 🤔 Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
| 🔢 Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
| 🎯 Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://docs.praison.ai/features/structured) |
| 🔗 LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://docs.praison.ai/features/langchain) |
| 📞 Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
| 🛠️ 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://docs.praison.ai/tools/tools) |
| 📄 YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://docs.praison.ai/developers/agents-playbook) |
| 💯 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
| 🔬 Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
| 🔄 Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
| 🌐 Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/agents/websearch) |
| 📥 Web Fetch (Anthropic) | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
| 💾 Prompt Caching | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
| 🧠 Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
| 💾 File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
| 🔍 Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
| 🔎 Unified Web Search | [Example](src/praisonai-agents/examples/web_search_example.py) | [📖](https://docs.praison.ai/tools/web-search) |
| 📋 Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
| 🔧 Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
| 🧠 Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
| 🔌 MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://docs.praison.ai/mcp/transports) |
| 🌐 WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
| 🔐 MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://docs.praison.ai/mcp/transports) |
| 🔄 MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
| ⚡ Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://docs.praison.ai/features/fast-context) |
| 🖼️ Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://docs.praison.ai/features/image-generation) |
| 📷 Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://docs.praison.ai/agents/image-to-text) |
| 🎬 Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://docs.praison.ai/agents/video) |
| 📊 Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://docs.praison.ai/agents/data-analyst) |
| 💰 Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://docs.praison.ai/agents/finance) |
| 🛒 Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://docs.praison.ai/agents/shopping) |
| ⭐ Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://docs.praison.ai/agents/recommendation) |
| 📖 Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://docs.praison.ai/agents/wikipedia) |
| 💻 Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
| 📝 Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
| 📝 Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
| 🔀 Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/model-router) |
| ⛓️ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
| 🔍 Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
| 👷 Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
| ⚡ Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://docs.praison.ai/features/parallelisation) |
| 🔁 Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://docs.praison.ai/features/repetitive) |
| 🤝 Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://docs.praison.ai/features/handoffs) |
| 🛡️ Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://docs.praison.ai/features/guardrails) |
| 💬 Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
| ✅ Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://docs.praison.ai/features/approval) |
| 🔄 Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/stateful-agents) |
| 🤖 Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://docs.praison.ai/features/autonomous-workflow) |
| 📜 Rules & Instructions | [Example](#6-rules--instructions) | [📖](https://docs.praison.ai/features/rules) |
| 🪝 Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
| 📈 Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
| 📹 Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/docs/cli/docs) |
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/docs/cli/mcp) |
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/docs/cli/commit) |
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/docs/cli/mentions) |
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
| **🧠 Memory & Knowledge** | | |
| ↳ Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
| ↳ File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
| ↳ Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
| ↳ Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
| ↳ RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
| ↳ Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
| **🔬 Research & Intelligence** | | |
| ↳ Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
| ↳ Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
| ↳ Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/agents/websearch) |
| ↳ Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
| ↳ Unified Web Search | [Example](src/praisonai-agents/examples/web_search_example.py) | [📖](https://docs.praison.ai/tools/web-search) |
| ↳ Web Fetch (Anthropic) | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
| **📋 Planning & Execution** | | |
| ↳ Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
| ↳ Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
| ↳ Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
| ↳ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
| ↳ Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
| ↳ Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
| **👥 Specialized Agents** | | |
| ↳ Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://docs.praison.ai/agents/data-analyst) |
| ↳ Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://docs.praison.ai/agents/finance) |
| ↳ Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://docs.praison.ai/agents/shopping) |
| ↳ Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://docs.praison.ai/agents/recommendation) |
| ↳ Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://docs.praison.ai/agents/wikipedia) |
| ↳ Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
| ↳ Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
| ↳ Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
| ↳ Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
| **🎨 Media & Multimodal** | | |
| ↳ Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://docs.praison.ai/features/image-generation) |
| ↳ Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://docs.praison.ai/agents/image-to-text) |
| ↳ Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://docs.praison.ai/agents/video) |
| ↳ Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
| **🔌 Protocols & Integration** | | |
| ↳ MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://docs.praison.ai/mcp/transports) |
| ↳ WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
| ↳ MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://docs.praison.ai/mcp/transports) |
| ↳ MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
| ↳ MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/docs/cli/mcp) |
| ↳ LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://docs.praison.ai/features/langchain) |
| **🛡️ Safety & Control** | | |
| ↳ Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://docs.praison.ai/features/guardrails) |
| ↳ Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://docs.praison.ai/features/approval) |
| ↳ Rules & Instructions | [Example](#6-rules--instructions) | [📖](https://docs.praison.ai/features/rules) |
| **⚙️ Advanced Features** | | |
| ↳ Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
| ↳ Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://docs.praison.ai/features/parallelisation) |
| ↳ Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://docs.praison.ai/features/repetitive) |
| ↳ Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://docs.praison.ai/features/handoffs) |
| ↳ Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/stateful-agents) |
| ↳ Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://docs.praison.ai/features/autonomous-workflow) |
| ↳ Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://docs.praison.ai/features/structured) |
| ↳ Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/model-router) |
| ↳ Prompt Caching | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
| ↳ Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://docs.praison.ai/features/fast-context) |
| **🛠️ Tools & Configuration** | | |
| ↳ 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://docs.praison.ai/tools/tools) |
| ↳ YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://docs.praison.ai/developers/agents-playbook) |
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
| **📊 Monitoring & Management** | | |
| ↳ Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
| ↳ Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
| ↳ History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
| ↳ Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
| ↳ Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/docs/cli/docs) |
| ↳ AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/docs/cli/commit) |
| ↳ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/docs/cli/mentions) |
| **🖥️ CLI Features** | | |
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/docs/cli/slash-commands) |
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/docs/cli/autonomy-modes) |
@@ -266,6 +277,23 @@ npm install praisonai
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/docs/cli/interactive-tui) |
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/docs/cli/git-integration) |
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/docs/cli/sandbox-execution) |
| ↳ CLI Compare | [Example](examples/compare/cli_compare_basic.py) | [📖](https://docs.praison.ai/docs/cli/compare) |
| ↳ Profile/Benchmark | [Example](#profile-benchmark) | [📖](https://docs.praison.ai/docs/cli/profile) |
| ↳ Auto Mode | [Example](#auto-mode) | [📖](https://docs.praison.ai/docs/cli/auto) |
| ↳ Init | [Example](#init) | [📖](https://docs.praison.ai/docs/cli/init) |
| ↳ File Input | [Example](#file-input) | [📖](https://docs.praison.ai/docs/cli/file-input) |
| ↳ Final Agent | [Example](#final-agent) | [📖](https://docs.praison.ai/docs/cli/final-agent) |
| ↳ Max Tokens | [Example](#max-tokens) | [📖](https://docs.praison.ai/docs/cli/max-tokens) |
| **🧪 Evaluation** | | |
| ↳ Accuracy Evaluation | [Example](examples/eval/accuracy_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
| ↳ Performance Evaluation | [Example](examples/eval/performance_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
| ↳ Reliability Evaluation | [Example](examples/eval/reliability_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
| ↳ Criteria Evaluation | [Example](examples/eval/criteria_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
| **🎯 Agent Skills** | | |
| ↳ Skills Management | [Example](examples/skills/basic_skill_usage.py) | [📖](https://docs.praison.ai/features/skills) |
| ↳ Custom Skills | [Example](examples/skills/custom_skill_example.py) | [📖](https://docs.praison.ai/features/skills) |
| **⏰ 24/7 Scheduling** | | |
| ↳ Agent Scheduler | [Example](examples/python/scheduled_agents/news_checker_live.py) | [📖](https://docs.praison.ai/docs/cli/scheduler) |
---
@@ -835,21 +863,75 @@ callbacks:
### 9. Hooks
Configure in `.praison/hooks.json`:
Intercept and modify agent behavior at various lifecycle points:
```python
from praisonaiagents.memory import HooksManager
from praisonaiagents.hooks import (
HookRegistry, HookRunner, HookEvent, HookResult,
BeforeToolInput
)
hooks = HooksManager()
# Create a hook registry
registry = HookRegistry()
# Register Python hooks
hooks.register("pre_write_code", lambda ctx: print(f"Writing {ctx['file']}"))
# Log all tool calls
@registry.on(HookEvent.BEFORE_TOOL)
def log_tools(event_data: BeforeToolInput) -> HookResult:
print(f"Tool: {event_data.tool_name}")
return HookResult.allow()
# Block dangerous operations
@registry.on(HookEvent.BEFORE_TOOL)
def security_check(event_data: BeforeToolInput) -> HookResult:
if "delete" in event_data.tool_name.lower():
return HookResult.deny("Delete operations blocked")
return HookResult.allow()
# Execute hooks
result = hooks.execute("pre_write_code", {"file": "main.py"})
runner = HookRunner(registry)
```
### 10. Field Names Reference (A-I-G-S)
**CLI Commands:**
```bash
praisonai hooks list # List registered hooks
praisonai hooks test before_tool # Test hooks for an event
praisonai hooks run "echo test" # Run a command hook
praisonai hooks validate hooks.json # Validate configuration
```
### 10. Shadow Git Checkpoints
File-level undo/restore using shadow git:
```python
from praisonaiagents.checkpoints import CheckpointService
service = CheckpointService(workspace_dir="./my_project")
await service.initialize()
# Save checkpoint before changes
result = await service.save("Before refactoring")
# Make changes...
# Restore if needed
await service.restore(result.checkpoint.id)
# View diff
diff = await service.diff()
```
**CLI Commands:**
```bash
praisonai checkpoint save "Before changes" # Save checkpoint
praisonai checkpoint list # List checkpoints
praisonai checkpoint diff # Show changes
praisonai checkpoint restore abc123 # Restore to checkpoint
```
### 11. Field Names Reference (A-I-G-S)
PraisonAI accepts both old (agents.yaml) and new (workflow.yaml) field names. Use the **canonical names** for new projects:
@@ -883,7 +965,7 @@ steps: # Define steps (not 'tasks')
> **Note:** The parser accepts both old and new names. Run `praisonai workflow validate <file.yaml>` to see suggestions for canonical names.
### 11. Extended agents.yaml with Workflow Patterns
### 12. Extended agents.yaml with Workflow Patterns
**Feature Parity:** Both `agents.yaml` and `workflow.yaml` now support the same features:
- All workflow patterns (route, parallel, loop, repeat)
@@ -959,6 +1041,124 @@ Run with the same simple command:
praisonai agents.yaml
```
### 12. MCP (Model Context Protocol)
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
#### MCP Client (Consume MCP Servers)
```python
from praisonaiagents import Agent, MCP
# stdio - Local NPX/Python servers
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
# Streamable HTTP - Production servers
agent = Agent(tools=MCP("https://api.example.com/mcp"))
# WebSocket - Real-time bidirectional
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
# SSE (Legacy) - Backward compatibility
agent = Agent(tools=MCP("http://localhost:8080/sse"))
# With environment variables
agent = Agent(
tools=MCP(
command="npx",
args=["-y", "@modelcontextprotocol/server-brave-search"],
env={"BRAVE_API_KEY": "your-key"}
)
)
# Multiple MCP servers + regular functions
def my_custom_tool(query: str) -> str:
"""Custom tool function."""
return f"Result: {query}"
agent = Agent(
name="MultiToolAgent",
instructions="Agent with multiple MCP servers",
tools=[
MCP("uvx mcp-server-time"), # Time tools
MCP("npx @modelcontextprotocol/server-memory"), # Memory tools
my_custom_tool # Regular function
]
)
```
#### MCP Server (Expose Tools as MCP Server)
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
```python
from praisonaiagents.mcp import ToolsMCPServer
def search_web(query: str, max_results: int = 5) -> dict:
"""Search the web for information."""
return {"results": [f"Result for {query}"]}
def calculate(expression: str) -> dict:
"""Evaluate a mathematical expression."""
return {"result": eval(expression)}
# Create and run MCP server
server = ToolsMCPServer(name="my-tools")
server.register_tools([search_web, calculate])
server.run() # stdio for Claude Desktop
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
```
#### MCP Features
| Feature | Description |
|---------|-------------|
| Session Management | Automatic Mcp-Session-Id handling |
| Protocol Versioning | Mcp-Protocol-Version header |
| Resumability | SSE stream recovery via Last-Event-ID |
| Security | Origin validation, DNS rebinding prevention |
| WebSocket | Auto-reconnect with exponential backoff |
### 13. A2A (Agent2Agent Protocol)
PraisonAI supports the [A2A Protocol](https://a2a-protocol.org) for agent-to-agent communication, enabling your agents to be discovered and collaborate with other AI agents.
#### A2A Server (Expose Agent as A2A Server)
```python
from praisonaiagents import Agent, A2A
from fastapi import FastAPI
# Create an agent with tools
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
agent = Agent(
name="Research Assistant",
role="Research Analyst",
goal="Help users research topics",
tools=[search_web]
)
# Expose as A2A Server
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
app = FastAPI()
app.include_router(a2a.get_router())
# Run: uvicorn app:app --reload
# Agent Card: GET /.well-known/agent.json
# Status: GET /status
```
#### A2A Features
| Feature | Description |
|---------|-------------|
| Agent Card | JSON metadata for agent discovery |
| Skills Extraction | Auto-generate skills from tools |
| Task Management | Stateful task lifecycle |
| Streaming | SSE streaming for real-time updates |
> **Documentation**: [docs.praison.ai/a2a](https://docs.praison.ai/a2a) | **Examples**: [examples/python/a2a](https://github.com/MervinPraison/PraisonAI/tree/main/examples/python/a2a)
---
## 🎯 CLI / No-Code Interface
@@ -1242,6 +1442,21 @@ praisonai "Analyze this data" --metrics
praisonai "Complex task" --metrics --planning
```
### Scheduler CLI:
```bash
praisonai schedule start <name> "task" --interval hourly
praisonai schedule list
praisonai schedule logs <name> [--follow]
praisonai schedule stop <name>
praisonai schedule restart <name>
praisonai schedule delete <name>
praisonai schedule describe <name>
praisonai schedule save <name> [file.yaml]
praisonai schedule "task" --interval hourly # foreground mode
praisonai schedule agents.yaml # foreground mode
```
### Image Processing CLI:
```bash
# Process images with vision-based tasks
@@ -1693,124 +1908,6 @@ agent = Agent(
| Web Fetch | Anthropic |
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
## MCP (Model Context Protocol)
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
### MCP Client (Consume MCP Servers)
```python
from praisonaiagents import Agent, MCP
# stdio - Local NPX/Python servers
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
# Streamable HTTP - Production servers
agent = Agent(tools=MCP("https://api.example.com/mcp"))
# WebSocket - Real-time bidirectional
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
# SSE (Legacy) - Backward compatibility
agent = Agent(tools=MCP("http://localhost:8080/sse"))
# With environment variables
agent = Agent(
tools=MCP(
command="npx",
args=["-y", "@modelcontextprotocol/server-brave-search"],
env={"BRAVE_API_KEY": "your-key"}
)
)
# Multiple MCP servers + regular functions
def my_custom_tool(query: str) -> str:
"""Custom tool function."""
return f"Result: {query}"
agent = Agent(
name="MultiToolAgent",
instructions="Agent with multiple MCP servers",
tools=[
MCP("uvx mcp-server-time"), # Time tools
MCP("npx @modelcontextprotocol/server-memory"), # Memory tools
my_custom_tool # Regular function
]
)
```
### MCP Server (Expose Tools as MCP Server)
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
```python
from praisonaiagents.mcp import ToolsMCPServer
def search_web(query: str, max_results: int = 5) -> dict:
"""Search the web for information."""
return {"results": [f"Result for {query}"]}
def calculate(expression: str) -> dict:
"""Evaluate a mathematical expression."""
return {"result": eval(expression)}
# Create and run MCP server
server = ToolsMCPServer(name="my-tools")
server.register_tools([search_web, calculate])
server.run() # stdio for Claude Desktop
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
```
### MCP Features
| Feature | Description |
|---------|-------------|
| Session Management | Automatic Mcp-Session-Id handling |
| Protocol Versioning | Mcp-Protocol-Version header |
| Resumability | SSE stream recovery via Last-Event-ID |
| Security | Origin validation, DNS rebinding prevention |
| WebSocket | Auto-reconnect with exponential backoff |
## A2A (Agent2Agent Protocol)
PraisonAI supports the [A2A Protocol](https://a2a-protocol.org) for agent-to-agent communication, enabling your agents to be discovered and collaborate with other AI agents.
### A2A Server (Expose Agent as A2A Server)
```python
from praisonaiagents import Agent, A2A
from fastapi import FastAPI
# Create an agent with tools
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
agent = Agent(
name="Research Assistant",
role="Research Analyst",
goal="Help users research topics",
tools=[search_web]
)
# Expose as A2A Server
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
app = FastAPI()
app.include_router(a2a.get_router())
# Run: uvicorn app:app --reload
# Agent Card: GET /.well-known/agent.json
# Status: GET /status
```
### A2A Features
| Feature | Description |
|---------|-------------|
| Agent Card | JSON metadata for agent discovery |
| Skills Extraction | Auto-generate skills from tools |
| Task Management | Stateful task lifecycle |
| Streaming | SSE streaming for real-time updates |
> **Documentation**: [docs.praison.ai/a2a](https://docs.praison.ai/a2a) | **Examples**: [examples/python/a2a](https://github.com/MervinPraison/PraisonAI/tree/main/examples/python/a2a)
## CLI Features
| Feature | Docs |
+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>=2.3.63" \
"praisonai>=2.4.0" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.63" \
"praisonai>=2.4.0" \
"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>=2.3.63" \
"praisonai>=2.4.0" \
"praisonai[ui]" \
"praisonai[crewai]"
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Basic Background Tasks Example for PraisonAI Agents.
This example demonstrates how to use the background task system to:
1. Run tasks asynchronously
2. Track task progress
3. Cancel running tasks
4. Handle task completion
Usage:
python basic_background.py
"""
import asyncio
import time
from praisonaiagents.background import BackgroundRunner, BackgroundConfig, TaskStatus
async def main():
print("=" * 60)
print("Background Tasks Demo")
print("=" * 60)
# Create a background runner with config
config = BackgroundConfig(max_concurrent_tasks=3)
runner = BackgroundRunner(config=config)
# Define some example tasks
def slow_task(duration: float, name: str) -> str:
"""A slow task that simulates work."""
print(f" 🔄 Task '{name}' starting (will take {duration}s)")
time.sleep(duration)
return f"Task '{name}' completed after {duration}s"
async def async_task(duration: float, name: str) -> str:
"""An async task that simulates work."""
print(f" 🔄 Async task '{name}' starting (will take {duration}s)")
await asyncio.sleep(duration)
return f"Async task '{name}' completed after {duration}s"
# Submit tasks
print("\n--- Submitting Tasks ---")
task1 = await runner.submit(slow_task, args=(1.0, "quick-job"), name="task1")
print(f"✅ Submitted task 1: {task1.id[:8]}")
task2 = await runner.submit(slow_task, args=(2.0, "medium-job"), name="task2")
print(f"✅ Submitted task 2: {task2.id[:8]}")
task3 = await runner.submit(async_task, args=(1.5, "async-job"), name="task3")
print(f"✅ Submitted async task 3: {task3.id[:8]}")
# List all tasks
print("\n--- Task Status ---")
for task in runner.tasks:
print(f" [{task.id[:8]}] {task.status.value}")
# Wait for task 1 to complete
print("\n--- Waiting for Task 1 ---")
await task1.wait(timeout=5.0)
print(f"✅ Task 1 result: {task1.result}")
# Check status of all tasks
print("\n--- Updated Task Status ---")
for task in [task1, task2, task3]:
status = "✅" if task.status == TaskStatus.COMPLETED else "🔄"
print(f" {status} [{task.id[:8]}] {task.status.value}")
# Wait for all remaining tasks
print("\n--- Waiting for All Tasks ---")
await task2.wait(timeout=10.0)
await task3.wait(timeout=10.0)
# Final status
print("\n--- Final Results ---")
for task in [task1, task2, task3]:
if task.result:
print(f" ✅ {task.result}")
# Cleanup
runner.clear_completed()
print("\n✅ Cleared completed tasks")
print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Basic Checkpoints Example for PraisonAI Agents.
This example demonstrates how to use the shadow git checkpointing system to:
1. Save checkpoints before making changes
2. List available checkpoints
3. View diffs between checkpoints
4. Restore to a previous checkpoint
Usage:
python basic_checkpoints.py
"""
import asyncio
import tempfile
from pathlib import Path
from praisonaiagents.checkpoints import CheckpointService
async def main():
# Create a temporary workspace for demonstration
with tempfile.TemporaryDirectory() as workspace:
print("=" * 60)
print("Shadow Git Checkpointing Demo")
print("=" * 60)
print(f"\nWorkspace: {workspace}")
# Create some initial files
test_file = Path(workspace) / "example.py"
test_file.write_text("# Initial content\nprint('Hello, World!')\n")
print(f"\n✅ Created initial file: {test_file.name}")
# Initialize the checkpoint service
service = CheckpointService(workspace_dir=workspace)
try:
# Initialize the shadow git repository
print("\n--- Initializing Checkpoint Service ---")
await service.initialize()
print("✅ Shadow git repository initialized")
# Save first checkpoint
print("\n--- Saving Checkpoint 1: Initial State ---")
result1 = await service.save("Initial state")
print(f"✅ Checkpoint saved: {result1.checkpoint.id[:8]}")
print(f" Message: {result1.checkpoint.message}")
# Make some changes
print("\n--- Making Changes ---")
test_file.write_text("# Modified content\nprint('Hello, PraisonAI!')\nprint('Checkpoints are awesome!')\n")
print("✅ Modified example.py")
# Create a new file
new_file = Path(workspace) / "utils.py"
new_file.write_text("def helper():\n return 'I help!'\n")
print("✅ Created utils.py")
# Save second checkpoint
print("\n--- Saving Checkpoint 2: After Changes ---")
result2 = await service.save("Added features")
print(f"✅ Checkpoint saved: {result2.checkpoint.id[:8]}")
# List all checkpoints
print("\n--- Listing All Checkpoints ---")
checkpoints = await service.list_checkpoints()
for i, cp in enumerate(checkpoints, 1):
print(f" {i}. [{cp.id[:8]}] {cp.message} ({cp.timestamp})")
# Show diff
print("\n--- Current Diff (uncommitted changes) ---")
diff = await service.diff()
if diff.files:
for f in diff.files:
print(f" {f.status}: {f.path}")
else:
print(" No uncommitted changes")
# Make more changes to show diff
test_file.write_text("# Even more changes\nprint('This will be reverted')\n")
print("\n✅ Made more changes to example.py")
diff = await service.diff()
print("\n--- New Diff ---")
if diff.files:
for f in diff.files:
print(f" {f.status}: {f.path}")
# Restore to checkpoint 2
print(f"\n--- Restoring to Checkpoint: {result2.checkpoint.id[:8]} ---")
await service.restore(result2.checkpoint.id)
print("✅ Restored successfully!")
# Verify restoration
content = test_file.read_text()
print(f"\n--- Verified Content After Restore ---")
print(content)
except Exception as e:
print(f"\n❌ Error: {e}")
raise
print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Basic Context Compaction Example for PraisonAI Agents.
This example demonstrates how to use context compaction to:
1. Manage conversation context length
2. Use different compaction strategies
3. Track compaction results
4. Preserve important messages
Usage:
python basic_compaction.py
"""
from praisonaiagents.compaction import (
ContextCompactor, CompactionConfig, CompactionStrategy, CompactionResult
)
def main():
print("=" * 60)
print("Context Compaction Demo")
print("=" * 60)
# ==========================================================================
# Sample Messages
# ==========================================================================
messages = [
{"role": "system", "content": "You are a helpful AI assistant."},
{"role": "user", "content": "Hello! Can you help me with Python?"},
{"role": "assistant", "content": "Of course! I'd be happy to help you with Python. What would you like to know?"},
{"role": "user", "content": "How do I read a file in Python?"},
{"role": "assistant", "content": "To read a file in Python, you can use the built-in open() function. Here's an example:\n\nwith open('filename.txt', 'r') as f:\n content = f.read()\n\nThis opens the file, reads its content, and automatically closes it."},
{"role": "user", "content": "What about writing to a file?"},
{"role": "assistant", "content": "To write to a file, use the 'w' mode:\n\nwith open('filename.txt', 'w') as f:\n f.write('Hello, World!')\n\nUse 'a' mode to append instead of overwriting."},
{"role": "user", "content": "Can you explain list comprehensions?"},
{"role": "assistant", "content": "List comprehensions are a concise way to create lists. Instead of:\n\nresult = []\nfor x in range(10):\n result.append(x * 2)\n\nYou can write:\n\nresult = [x * 2 for x in range(10)]"},
]
print(f"\n--- Original Messages ---")
print(f" Message count: {len(messages)}")
# ==========================================================================
# Basic Compaction
# ==========================================================================
print("\n--- Basic Compaction (Truncate Strategy) ---")
compactor = ContextCompactor(
max_tokens=50, # Low limit to trigger compaction
strategy=CompactionStrategy.TRUNCATE,
preserve_system=True,
preserve_recent=2
)
# Check stats before
stats = compactor.get_stats(messages)
print(f" Total tokens: {stats['total_tokens']}")
print(f" Max tokens: {stats['max_tokens']}")
print(f" Needs compaction: {stats['needs_compaction']}")
# Compact
compacted, result = compactor.compact(messages)
print(f"\n After compaction:")
print(f" Messages: {len(messages)} -> {len(compacted)}")
print(f" Tokens: {result.original_tokens} -> {result.compacted_tokens}")
print(f" Saved: {result.tokens_saved} tokens")
print(f" Compression: {result.compression_ratio:.1%}")
# ==========================================================================
# Sliding Window Strategy
# ==========================================================================
print("\n--- Sliding Window Strategy ---")
compactor = ContextCompactor(
max_tokens=50,
strategy=CompactionStrategy.SLIDING,
preserve_system=True,
preserve_recent=3
)
compacted, result = compactor.compact(messages)
print(f" Messages kept: {result.messages_kept}")
print(f" Messages removed: {result.messages_removed}")
print(f" Strategy: {result.strategy_used.value}")
# ==========================================================================
# Summarize Strategy
# ==========================================================================
print("\n--- Summarize Strategy ---")
compactor = ContextCompactor(
max_tokens=50,
strategy=CompactionStrategy.SUMMARIZE,
preserve_system=True,
preserve_recent=2
)
compacted, result = compactor.compact(messages)
print(f" Messages kept: {result.messages_kept}")
print(f" Strategy: {result.strategy_used.value}")
# Show what messages remain
print(f"\n Remaining message roles:")
for msg in compacted:
role = msg.get("role", "unknown")
content_preview = msg.get("content", "")[:50]
print(f" [{role}] {content_preview}...")
# ==========================================================================
# No Compaction Needed
# ==========================================================================
print("\n--- No Compaction Needed ---")
compactor = ContextCompactor(
max_tokens=10000, # High limit
strategy=CompactionStrategy.TRUNCATE
)
compacted, result = compactor.compact(messages)
print(f" Was compacted: {result.was_compacted}")
print(f" Messages unchanged: {len(compacted) == len(messages)}")
# ==========================================================================
# Result Serialization
# ==========================================================================
print("\n--- Result Serialization ---")
compactor = ContextCompactor(max_tokens=50)
_, result = compactor.compact(messages)
data = result.to_dict()
print(f" Original tokens: {data['original_tokens']}")
print(f" Compacted tokens: {data['compacted_tokens']}")
print(f" Compression ratio: {data['compression_ratio']:.2f}")
print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""
CLI Compare Example - Advanced Usage
This example demonstrates advanced comparison features including
custom mode configurations and result analysis.
"""
from praisonai.cli.features.compare import (
CompareHandler,
CompareResult,
ModeResult,
get_mode_config,
save_compare_result,
format_comparison_table,
)
# Create custom mode configurations
custom_modes = {
"fast": {"llm": "gpt-4o-mini"},
"accurate": {"llm": "gpt-4o", "planning": True},
"research": {"research": True, "web_search": True},
}
# Create handler with verbose output
handler = CompareHandler(verbose=True)
# Example: Manually create comparison results for analysis
# (useful for testing without API calls)
mock_result = CompareResult(
query="What are the latest AI trends?",
comparisons=[
ModeResult(
mode="basic",
output="AI trends include LLMs, multimodal models, and agents.",
execution_time_ms=1234.5,
model_used="gpt-4o-mini",
tools_used=[]
),
ModeResult(
mode="tools",
output="Based on recent search results, AI trends in 2024 include...",
execution_time_ms=2567.8,
model_used="gpt-4o-mini",
tools_used=["internet_search"]
),
ModeResult(
mode="research",
output="Comprehensive research shows that AI trends...",
execution_time_ms=5432.1,
model_used="gpt-4o-mini",
tools_used=["internet_search", "web_scraper"]
),
]
)
# Get summary statistics
summary = mock_result.get_summary()
print("Comparison Summary:")
print(f" Fastest mode: {summary['fastest']} ({summary['fastest_time_ms']:.1f}ms)")
print(f" Slowest mode: {summary['slowest']} ({summary['slowest_time_ms']:.1f}ms)")
# Format as table
print("\n" + format_comparison_table(mock_result))
# Convert to JSON for storage
print("\nJSON Output:")
print(mock_result.to_json())
# Save to file
# save_compare_result(mock_result, "advanced_comparison.json")
print("\n--- CLI Usage Examples ---")
print("""
# Basic comparison
praisonai "What is AI?" --compare "basic,tools"
# Compare with specific model
praisonai "Explain quantum computing" --compare "basic,planning,research" --model gpt-4o
# Save results to file
praisonai "Latest tech news" --compare "basic,tools,web_search" --compare-output results.json
# Verbose output
praisonai "Write a poem" --compare "basic,planning" --verbose
""")
+45
View File
@@ -0,0 +1,45 @@
"""
CLI Compare Example - Basic Usage
This example demonstrates how to compare different CLI modes programmatically.
"""
from praisonai.cli.features.compare import (
CompareHandler,
get_mode_config,
list_available_modes,
parse_modes,
)
# List all available comparison modes
print("Available modes:")
for mode in list_available_modes():
config = get_mode_config(mode)
print(f" - {mode}: {config}")
# Parse modes from comma-separated string
modes = parse_modes("basic,tools,planning")
print(f"\nParsed modes: {modes}")
# Create compare handler
handler = CompareHandler(verbose=True)
# Compare modes (this will run actual agent calls)
# Uncomment to run:
# result = handler.compare(
# query="What is artificial intelligence?",
# modes=["basic", "tools"],
# model="gpt-4o-mini"
# )
# handler.print_result(result)
# Save results to file
# result = handler.execute(
# query="Explain machine learning",
# modes_str="basic,planning",
# output_path="comparison_results.json"
# )
print("\nTo run comparison via CLI:")
print(' praisonai "What is AI?" --compare "basic,tools,planning"')
print(' praisonai "What is AI?" --compare "basic,research" --compare-output results.json')
@@ -0,0 +1,23 @@
# Example: Data Collection Agent with 30-minute Schedule
# Collects and analyzes data every 30 minutes
framework: praisonai
agents:
- name: "Data Collector"
role: "Data Analysis Specialist"
goal: "Collect and analyze market data at regular intervals"
instructions: |
You are a data analysis specialist focused on collecting and analyzing
market trends and metrics. Provide actionable insights from the data.
tools:
- search_tool
verbose: false
task: "Collect latest market data and identify key trends"
# Schedule: Every 30 minutes with immediate execution
schedule:
interval: "*/30m"
max_retries: 5
run_immediately: true
@@ -0,0 +1,53 @@
# Example: 24/7 AI News Monitor with Scheduler
# This agent runs continuously at regular intervals to monitor AI news
framework: praisonai
agents:
- name: "AI News Monitor"
role: "Technology News Analyst"
goal: "Monitor and summarize the latest AI developments"
instructions: |
You are an expert technology news analyst specializing in AI and machine learning.
Your job is to search for the latest AI news, identify the most important developments,
and provide clear, concise summaries with sources.
Focus on:
- Major AI product launches
- Research breakthroughs
- Industry trends
- Policy and regulation updates
tools:
- search_tool
verbose: true
# Task to execute on each scheduled run
task: |
Search for the latest AI news from the past 24 hours.
Provide the top 3 most important stories with:
1. Headline
2. Brief summary (2-3 sentences)
3. Source URL
4. Why it's significant
# Schedule configuration
schedule:
# Interval options:
# - "hourly" - Every hour
# - "daily" - Every 24 hours
# - "*/30m" - Every 30 minutes
# - "*/6h" - Every 6 hours
# - "3600" - Custom seconds
interval: "hourly"
# Maximum retry attempts if execution fails
max_retries: 3
# Run immediately when scheduler starts (before first scheduled interval)
run_immediately: true
# Optional: Variables for customization
variables:
news_category: "AI and Machine Learning"
region: "Global"
time_range: "24 hours"
+69
View File
@@ -0,0 +1,69 @@
"""
Accuracy Evaluation Example
This example demonstrates how to evaluate agent output accuracy
by comparing against expected outputs using LLM-as-judge.
"""
import os
from praisonaiagents import Agent
from praisonaiagents.eval import AccuracyEvaluator
# Check if we have an API key
has_api_key = os.getenv("OPENAI_API_KEY") is not None
if has_api_key:
# Create a simple math tutor agent
agent = Agent(
instructions="You are a math tutor. Answer math questions concisely with just the number."
)
# Create accuracy evaluator
evaluator = AccuracyEvaluator(
agent=agent,
input_text="What is 2 + 2?",
expected_output="4",
num_iterations=3, # Run 3 times for statistical significance
verbose=True
)
# Run evaluation
result = evaluator.run(print_summary=True)
# Check results
print(f"\nAverage Score: {result.avg_score}/10")
print(f"Passed: {result.passed}")
else:
print("⚠️ No OPENAI_API_KEY found. Using mock evaluation...")
# Create evaluator without agent for mock testing
evaluator = AccuracyEvaluator(
func=lambda x: "4", # Mock function
input_text="What is 2 + 2?",
expected_output="4",
num_iterations=1,
verbose=True
)
# Test with mock output
try:
result = evaluator.run(print_summary=True)
print(f"\nMock Average Score: {result.avg_score}/10")
print(f"Mock Passed: {result.passed}")
except Exception as e:
print(f"Mock evaluation failed: {e}")
print("This is expected without an API key")
# You can also evaluate pre-generated outputs
print("\n--- Testing pre-generated output evaluation ---")
output = "The answer is 4"
if has_api_key:
try:
result2 = evaluator.evaluate_output(output, print_summary=True)
print(f"Pre-generated output score: {result2.avg_score}/10")
except Exception as e:
print(f"Pre-generated evaluation failed: {e}")
else:
print("⚠️ Skipping pre-generated evaluation (no API key)")
print("To run full evaluation, set OPENAI_API_KEY environment variable")
+105
View File
@@ -0,0 +1,105 @@
"""
Batch Evaluation Example
This example demonstrates how to run batch evaluations
from a JSON test file.
"""
import os
import json
import tempfile
from praisonaiagents.eval import AccuracyEvaluator
# Check if we have an API key
has_api_key = os.getenv("OPENAI_API_KEY") is not None
# Create a test file with multiple test cases
test_cases = [
{
"input": "What is 2 + 2?",
"expected": "4"
},
{
"input": "What is the capital of France?",
"expected": "Paris"
},
{
"input": "What color is the sky?",
"expected": "Blue"
}
]
# Save test cases to a temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(test_cases, f)
test_file = f.name
print(f"Test file created: {test_file}")
print(f"Running {len(test_cases)} test cases...")
if has_api_key:
# You can use the CLI for batch evaluation:
# praisonai eval batch --agent agents.yaml --test-file tests.json --batch-type accuracy
# Or programmatically:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant. Answer questions concisely."
)
results = []
for i, test_case in enumerate(test_cases):
print(f"\nTest {i + 1}: {test_case['input']}")
evaluator = AccuracyEvaluator(
agent=agent,
input_text=test_case["input"],
expected_output=test_case["expected"],
num_iterations=1
)
result = evaluator.run()
results.append({
"input": test_case["input"],
"expected": test_case["expected"],
"score": result.avg_score,
"passed": result.passed
})
print(f" Score: {result.avg_score}/10")
print(f" Passed: {result.passed}")
# Summary
passed = sum(1 for r in results if r["passed"])
print(f"\n{'='*50}")
print(f"Summary: {passed}/{len(results)} tests passed")
print(f"Pass Rate: {passed/len(results):.1%}")
else:
print("⚠️ No OPENAI_API_KEY found. Running mock batch evaluation...")
# Mock evaluation without API key
results = []
for i, test_case in enumerate(test_cases):
print(f"\nTest {i + 1}: {test_case['input']}")
# Mock result (all would fail without API key)
mock_score = 0.0
mock_passed = False
results.append({
"input": test_case["input"],
"expected": test_case["expected"],
"score": mock_score,
"passed": mock_passed
})
print(f" Score: {mock_score}/10 (mock)")
print(f" Passed: {mock_passed} (expected without API key)")
# Summary
passed = sum(1 for r in results if r["passed"])
print(f"\n{'='*50}")
print(f"Summary: {passed}/{len(results)} tests passed (mock evaluation)")
print(f"Pass Rate: {passed/len(results):.1%}")
print("\nTo run real batch evaluation, set OPENAI_API_KEY environment variable")
+82
View File
@@ -0,0 +1,82 @@
"""
Criteria Evaluation Example
This example demonstrates how to evaluate agent outputs
against custom criteria using LLM-as-judge.
"""
import os
from praisonaiagents import Agent
from praisonaiagents.eval import CriteriaEvaluator
# Check if we have an API key
has_api_key = os.getenv("OPENAI_API_KEY") is not None
if has_api_key:
print("--- Testing Criteria Evaluation with Agent ---")
# Create a customer service agent
agent = Agent(
instructions="You are a friendly customer service agent. Be helpful and empathetic."
)
# Create criteria evaluator with numeric scoring
evaluator = CriteriaEvaluator(
criteria="Response is helpful, empathetic, and provides a clear solution",
agent=agent,
input_text="I'm frustrated because my order hasn't arrived yet.",
scoring_type="numeric", # Score 1-10
threshold=7.0, # Pass if score >= 7
num_iterations=2,
verbose=True
)
# Run evaluation
result = evaluator.run(print_summary=True)
print("\nNumeric Scoring Results:")
print(f" Average Score: {result.avg_score}/10")
print(f" Pass Rate: {result.pass_rate:.1%}")
print(f" Passed: {result.passed}")
# Binary scoring example (pass/fail)
print("\n--- Testing Binary Scoring ---")
binary_evaluator = CriteriaEvaluator(
criteria="Response does not contain any offensive language",
agent=agent,
input_text="Tell me a joke",
scoring_type="binary", # Pass or Fail
verbose=True
)
binary_result = binary_evaluator.run(print_summary=True)
# With on_fail callback
print("\n--- Testing Failure Callback ---")
def handle_failure(score):
print(f"ALERT: Evaluation failed with score {score.score}")
print(f"Reasoning: {score.reasoning}")
callback_evaluator = CriteriaEvaluator(
criteria="Response is professional and helpful",
agent=agent,
input_text="Help me",
on_fail=handle_failure,
threshold=8.0
)
callback_evaluator.run()
else:
print("⚠️ No OPENAI_API_KEY found. Skipping agent-based criteria evaluation...")
# Test pre-generated output evaluation (doesn't need agent)
print("\n--- Testing Pre-generated Output Evaluation ---")
if has_api_key:
output = "I understand your frustration. Let me check on your order right away."
try:
result2 = evaluator.evaluate_output(output, print_summary=True)
print(f"Pre-generated output score: {result2.avg_score}/10")
except Exception as e:
print(f"Pre-generated evaluation failed: {e}")
else:
print("⚠️ Skipping pre-generated evaluation (no API key)")
print("To run full evaluation, set OPENAI_API_KEY environment variable")
+59
View File
@@ -0,0 +1,59 @@
"""
Performance Evaluation Example
This example demonstrates how to benchmark agent performance
by measuring runtime and memory usage across multiple iterations.
"""
import os
from praisonaiagents import Agent
from praisonaiagents.eval import PerformanceEvaluator
# Check if we have an API key
has_api_key = os.getenv("OPENAI_API_KEY") is not None
if has_api_key:
print("--- Testing Agent Performance ---")
# Create a simple agent
agent = Agent(
instructions="You are a helpful assistant. Answer questions briefly."
)
# Create performance evaluator
evaluator = PerformanceEvaluator(
agent=agent,
input_text="What is the capital of France?",
num_iterations=10, # Run 10 benchmark iterations
warmup_runs=2, # 2 warmup runs before measurement
track_memory=True, # Track memory usage
verbose=True
)
# Run evaluation
result = evaluator.run(print_summary=True)
# Access detailed metrics
print("\nAgent Performance Results:")
print(f" Average Time: {result.avg_run_time:.4f}s")
print(f" Min Time: {result.min_run_time:.4f}s")
print(f" Max Time: {result.max_run_time:.4f}s")
print(f" Median Time: {result.median_run_time:.4f}s")
print(f" P95 Time: {result.p95_run_time:.4f}s")
print(f" Avg Memory: {result.avg_memory:.2f} MB")
else:
print("⚠️ No OPENAI_API_KEY found. Skipping agent performance test...")
# You can also benchmark any function
def my_function():
import time
time.sleep(0.1)
return "done"
func_evaluator = PerformanceEvaluator(
func=my_function,
num_iterations=5,
warmup_runs=1,
track_memory=False
)
func_result = func_evaluator.run(print_summary=True)
+72
View File
@@ -0,0 +1,72 @@
"""
Reliability Evaluation Example
This example demonstrates how to verify that agents call
the expected tools during execution.
"""
import os
from praisonaiagents import Agent
from praisonaiagents.eval import ReliabilityEvaluator
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
def calculate(expression: str) -> str:
"""Calculate a math expression."""
return str(eval(expression))
# Check if we have an API key
has_api_key = os.getenv("OPENAI_API_KEY") is not None
if has_api_key:
print("--- Testing Agent Tool Call Reliability ---")
# Create an agent with tools
agent = Agent(
instructions="You are a helpful assistant with access to search and calculator tools.",
tools=[search_web, calculate]
)
# Create reliability evaluator
evaluator = ReliabilityEvaluator(
agent=agent,
input_text="Search for the weather in Paris and calculate 25 * 4",
expected_tools=["search_web", "calculate"], # Tools that should be called
forbidden_tools=["delete_file"], # Tools that should NOT be called
verbose=True
)
# Run evaluation
result = evaluator.run(print_summary=True)
# Check results
print("\nAgent Reliability Results:")
print(f" Passed: {result.passed}")
print(f" Pass Rate: {result.pass_rate:.1%}")
else:
print("⚠️ No OPENAI_API_KEY found. Skipping agent reliability test...")
print("Agent would fail to call tools without API key (expected behavior)")
# You can also evaluate pre-recorded tool calls (doesn't need API key)
print("\n--- Testing Pre-recorded Tool Call Evaluation ---")
# Create a mock agent for tool call evaluation
mock_agent = Agent(instructions="Mock agent")
evaluator = ReliabilityEvaluator(
agent=mock_agent,
expected_tools=["search_web", "calculate"], # Tools that should be called
forbidden_tools=["delete_file"], # Tools that should NOT be called
verbose=True
)
result2 = evaluator.evaluate_tool_calls(
actual_tools=["search_web", "calculate"],
print_summary=True
)
print("\nPre-recorded Tool Call Results:")
print(f" Passed: {result2.passed}")
print(f" Pass Rate: {result2.pass_rate:.1%}")
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Basic Hooks Example for PraisonAI Agents.
This example demonstrates how to use the hooks system to:
1. Log tool calls before execution
2. Block dangerous operations
3. Add context to agent responses
4. Monitor session lifecycle
Usage:
python basic_hooks.py
"""
import asyncio
from praisonaiagents import Agent
from praisonaiagents.hooks import (
HookRegistry, HookRunner, HookEvent, HookResult,
BeforeToolInput, AfterToolInput
)
def main():
# Create a hook registry
registry = HookRegistry()
# ==========================================================================
# Hook 1: Log all tool calls
# ==========================================================================
@registry.on(HookEvent.BEFORE_TOOL)
def log_tool_calls(event_data: BeforeToolInput) -> HookResult:
"""Log every tool call before execution."""
print(f"\n📝 [LOG] Tool: {event_data.tool_name}")
print(f" Input: {event_data.tool_input}")
return HookResult.allow()
# ==========================================================================
# Hook 2: Block dangerous file operations (using function logic)
# ==========================================================================
@registry.on(HookEvent.BEFORE_TOOL)
def block_delete_operations(event_data: BeforeToolInput) -> HookResult:
"""Block any delete or remove operations."""
dangerous_keywords = ['delete', 'remove', 'rm', 'unlink']
tool_lower = event_data.tool_name.lower()
if any(kw in tool_lower for kw in dangerous_keywords):
print(f"\n🚫 [BLOCKED] Dangerous operation: {event_data.tool_name}")
return HookResult.deny(
f"Operation '{event_data.tool_name}' is blocked by security policy"
)
return HookResult.allow()
# ==========================================================================
# Hook 3: Require confirmation for write operations
# ==========================================================================
@registry.on(HookEvent.BEFORE_TOOL)
def confirm_write_operations(event_data: BeforeToolInput) -> HookResult:
"""Request confirmation for write operations."""
write_keywords = ['write', 'save', 'create', 'update']
tool_lower = event_data.tool_name.lower()
if any(kw in tool_lower for kw in write_keywords):
print(f"\n⚠️ [CONFIRM] Write operation: {event_data.tool_name}")
# In a real scenario, you might prompt the user here
# For this example, we'll auto-approve
return HookResult.allow("Auto-approved for demo")
return HookResult.allow()
# ==========================================================================
# Hook 4: Add timing information after tool execution
# ==========================================================================
@registry.on(HookEvent.AFTER_TOOL)
def log_tool_completion(event_data: AfterToolInput) -> HookResult:
"""Log tool completion with timing."""
print(f"\n✅ [DONE] Tool: {event_data.tool_name}")
print(f" Duration: {event_data.execution_time_ms:.2f}ms")
if event_data.tool_error:
print(f" Error: {event_data.tool_error}")
return HookResult.allow()
# ==========================================================================
# Test the hooks
# ==========================================================================
print("=" * 60)
print("Testing Hooks System")
print("=" * 60)
runner = HookRunner(registry)
# Test 1: Normal tool call (should be logged and allowed)
print("\n--- Test 1: Normal read operation ---")
input1 = BeforeToolInput(
session_id="test",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="read_file",
tool_input={"path": "/tmp/test.txt"}
)
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input1))
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
# Test 2: Delete operation (should be blocked)
print("\n--- Test 2: Delete operation ---")
input2 = BeforeToolInput(
session_id="test",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="delete_file",
tool_input={"path": "/important/data.txt"}
)
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input2))
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
if HookRunner.is_blocked(results):
print(f"Reason: {HookRunner.get_blocking_reason(results)}")
# Test 3: Write operation (should be confirmed)
print("\n--- Test 3: Write operation ---")
input3 = BeforeToolInput(
session_id="test",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="write_file",
tool_input={"path": "/tmp/output.txt", "content": "Hello"}
)
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input3))
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
# Show registered hooks
print("\n" + "=" * 60)
print("Registered Hooks:")
print("=" * 60)
for event, hooks in registry.list_hooks().items():
print(f"\n{event}:")
for hook in hooks:
print(f" - {hook['name']} (matcher: {hook['matcher'] or '*'})")
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Basic Policy Engine Example for PraisonAI Agents.
This example demonstrates how to use the policy engine to:
1. Create policies with rules
2. Check if actions are allowed
3. Block dangerous operations
4. Use convenience policy creators
Usage:
python basic_policy.py
"""
from praisonaiagents.policy import (
PolicyEngine, Policy, PolicyRule, PolicyAction, PolicyConfig,
create_deny_tools_policy, create_read_only_policy
)
def main():
print("=" * 60)
print("Policy Engine Demo")
print("=" * 60)
# Create a policy engine
engine = PolicyEngine()
# ==========================================================================
# Policy 1: Block dangerous tools
# ==========================================================================
print("\n--- Creating Policies ---")
dangerous_tools_policy = Policy(
name="block_dangerous",
description="Block dangerous file operations",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="Delete operations are blocked",
name="deny_delete"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:rm_*",
reason="Remove operations are blocked",
name="deny_rm"
),
],
priority=100
)
engine.add_policy(dangerous_tools_policy)
print(f"✅ Added policy: {dangerous_tools_policy.name}")
# ==========================================================================
# Policy 2: Allow read operations
# ==========================================================================
read_policy = Policy(
name="allow_read",
description="Allow read operations",
rules=[
PolicyRule(
action=PolicyAction.ALLOW,
resource="tool:read_*",
name="allow_read"
),
PolicyRule(
action=PolicyAction.ALLOW,
resource="tool:list_*",
name="allow_list"
),
]
)
engine.add_policy(read_policy)
print(f"✅ Added policy: {read_policy.name}")
# ==========================================================================
# Test policy checks
# ==========================================================================
print("\n--- Testing Policy Checks ---")
test_cases = [
("tool:read_file", "Reading a file"),
("tool:list_directory", "Listing directory"),
("tool:delete_file", "Deleting a file"),
("tool:rm_directory", "Removing directory"),
("tool:write_file", "Writing a file"),
]
for resource, description in test_cases:
result = engine.check(resource, {})
status = "✅ ALLOWED" if result.allowed else "🚫 DENIED"
reason = f" ({result.reason})" if result.reason else ""
print(f" {status}: {description}{reason}")
# ==========================================================================
# Using convenience functions
# ==========================================================================
print("\n--- Using Convenience Functions ---")
# Create a read-only policy
read_only = create_read_only_policy()
print(f"✅ Created read-only policy with {len(read_only.rules)} rules")
# Create a deny tools policy
deny_tools = create_deny_tools_policy(
["execute_*", "shell_*", "system_*"],
reason="System commands are blocked"
)
print(f"✅ Created deny-tools policy with {len(deny_tools.rules)} rules")
# ==========================================================================
# Strict mode example
# ==========================================================================
print("\n--- Strict Mode Example ---")
strict_engine = PolicyEngine(PolicyConfig(strict_mode=True))
# In strict mode, unknown resources are denied
result = strict_engine.check("tool:unknown_operation", {})
print(f" Unknown operation in strict mode: {'DENIED' if not result.allowed else 'ALLOWED'}")
# ==========================================================================
# Policy serialization
# ==========================================================================
print("\n--- Policy Serialization ---")
policy_dict = dangerous_tools_policy.to_dict()
print(f" Serialized policy: {policy_dict['name']} with {len(policy_dict['rules'])} rules")
# Recreate from dict
restored = Policy.from_dict(policy_dict)
print(f" Restored policy: {restored.name}")
print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,115 @@
"""
24/7 News Checker Agent Example
This example demonstrates how to create a continuously running agent
that checks for AI news every hour and prints the results.
Usage:
python news_checker_agent.py
The agent will:
1. Run immediately on start
2. Check for latest AI news every 1 hour
3. Print results to console
4. Continue running until stopped (Ctrl+C)
"""
import os
import sys
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
from praisonaiagents import Agent
from praisonai.agent_scheduler import AgentScheduler
from tools import search_tool
def on_success(result):
"""Callback function called when agent execution succeeds."""
print("\n" + "="*80)
print(f"✅ SUCCESS - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("="*80)
print(result)
print("="*80 + "\n")
def on_failure(error):
"""Callback function called when agent execution fails."""
print("\n" + "="*80)
print(f"❌ FAILURE - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("="*80)
print(f"Error: {error}")
print("="*80 + "\n")
def main():
"""Main function to set up and run the scheduled news checker agent."""
print("\n🤖 Starting 24/7 News Checker Agent")
print("="*80)
# Create the agent with web search capability
agent = Agent(
name="NewsChecker",
role="AI News Monitor",
goal="Monitor and summarize the latest AI and technology news",
backstory="""You are an expert AI news analyst who stays up-to-date
with the latest developments in artificial intelligence, machine learning,
and technology. You provide concise, accurate summaries of important news.""",
tools=[search_tool],
verbose=True
)
# Define the task
task = """Search for the latest AI news from today.
Focus on major developments, breakthroughs, or announcements.
Provide a summary of the top 3-5 most important news items.
Include the source and key details for each item."""
# Create scheduler with callbacks
scheduler = AgentScheduler(
agent=agent,
task=task,
on_success=on_success,
on_failure=on_failure
)
print(f"Agent: {agent.name}")
print(f"Task: {task}")
print(f"Schedule: Every 1 hour")
print("="*80)
print("\n⏰ Starting scheduler... (Press Ctrl+C to stop)\n")
# Start the scheduler
# - "hourly" or "*/1h" = every 1 hour
# - run_immediately=True = run once before starting the schedule
scheduler.start(
schedule_expr="hourly", # Run every hour
max_retries=3, # Retry up to 3 times on failure
run_immediately=True # Run immediately on start
)
try:
# Keep the main thread alive
while scheduler.is_running:
import time
time.sleep(1)
except KeyboardInterrupt:
print("\n\n🛑 Stopping scheduler...")
scheduler.stop()
# Print final statistics
stats = scheduler.get_stats()
print("\n📊 Final Statistics:")
print(f" Total Executions: {stats['total_executions']}")
print(f" Successful: {stats['successful_executions']}")
print(f" Failed: {stats['failed_executions']}")
print(f" Success Rate: {stats['success_rate']:.1f}%")
print("\n✅ Agent stopped successfully\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,143 @@
"""
Live News Checker Agent - 24/7 Scheduled Agent
This example demonstrates a real working agent that checks AI news
every hour using the AgentScheduler with actual API calls.
Requirements:
- OPENAI_API_KEY environment variable set
- praisonaiagents package installed
Usage:
python news_checker_live.py
"""
import os
import sys
from datetime import datetime
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../..'))
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler
# Import search_tool from root tools.py
try:
from tools import search_tool
except ImportError:
# Fallback: create inline search tool
from duckduckgo_search import DDGS
def search_tool(query: str) -> list:
"""Search the web using DuckDuckGo."""
try:
results = []
ddgs = DDGS()
for result in ddgs.text(keywords=query, max_results=5):
results.append({
"title": result.get("title", ""),
"url": result.get("href", ""),
"snippet": result.get("body", ""),
})
return results
except Exception as e:
print(f"Search error: {e}")
return []
def on_success(result):
"""Callback when agent execution succeeds."""
print("\n" + "="*80)
print(f"✅ SUCCESS - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("="*80)
print(result)
print("="*80 + "\n")
def on_failure(error):
"""Callback when agent execution fails."""
print("\n" + "="*80)
print(f"❌ FAILURE - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("="*80)
print(f"Error: {error}")
print("="*80 + "\n")
def main():
"""Main function to run the scheduled news checker."""
# Check for API key
if not os.getenv("OPENAI_API_KEY"):
print("❌ Error: OPENAI_API_KEY environment variable not set")
print("Please set it with: export OPENAI_API_KEY=your_key_here")
sys.exit(1)
print("\n🤖 Starting 24/7 AI News Checker Agent")
print("="*80)
# Create the agent with web search capability
agent = Agent(
name="AI News Monitor",
role="Technology News Analyst",
goal="Monitor and summarize the latest AI and technology news",
backstory="""You are an expert AI news analyst who stays up-to-date
with the latest developments in artificial intelligence, machine learning,
and technology. You provide concise, accurate summaries of important news.""",
tools=[search_tool],
verbose=True,
self_reflect=False # Disable reflection for faster execution
)
# Define the task
task = """Search for the latest AI news from today.
Focus on major developments, breakthroughs, or announcements.
Provide a summary of the top 3 most important news items.
Include the source and key details for each item."""
# Create scheduler with callbacks
scheduler = AgentScheduler(
agent=agent,
task=task,
on_success=on_success,
on_failure=on_failure
)
print(f"Agent: {agent.name}")
print(f"Task: {task}")
print(f"Schedule: Every 1 hour")
print("="*80)
print("\n⏰ Starting scheduler... (Press Ctrl+C to stop)\n")
# Start the scheduler
# For testing: use "*/2m" (every 2 minutes) instead of "hourly"
# For production: use "hourly"
scheduler.start(
schedule_expr="*/2m", # Every 2 minutes for testing
max_retries=3,
run_immediately=True # Run once immediately
)
try:
# Keep the main thread alive
while scheduler.is_running:
import time
time.sleep(1)
except KeyboardInterrupt:
print("\n\n🛑 Stopping scheduler...")
scheduler.stop()
# Print final statistics
stats = scheduler.get_stats()
print("\n📊 Final Statistics:")
print(f" Total Executions: {stats['total_executions']}")
print(f" Successful: {stats['successful_executions']}")
print(f" Failed: {stats['failed_executions']}")
print(f" Success Rate: {stats['success_rate']:.1f}%")
print("\n✅ Agent stopped successfully\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
"""
Simple test script for 24/7 Agent Scheduler
This script demonstrates the basic usage of AgentScheduler
for running agents periodically.
Usage:
python simple_test.py
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
from unittest.mock import Mock
from praisonai.scheduler import AgentScheduler
# Create a mock agent for testing
mock_agent = Mock()
mock_agent.name = "TestAgent"
mock_agent.start = Mock(return_value="Test execution successful!")
# Create scheduler
scheduler = AgentScheduler(
agent=mock_agent,
task="Test task execution"
)
print("🤖 Testing AgentScheduler")
print("=" * 60)
# Test 1: Execute once
print("\n✅ Test 1: Execute once")
result = scheduler.execute_once()
print(f"Result: {result}")
# Test 2: Get stats
print("\n✅ Test 2: Get statistics")
stats = scheduler.get_stats()
print(f"Stats: {stats}")
# Test 3: Start scheduler (will run every 5 seconds)
print("\n✅ Test 3: Start scheduler (*/5s)")
scheduler.start("*/5s", run_immediately=True)
import time
print("Running for 12 seconds...")
time.sleep(12)
# Stop scheduler
print("\n✅ Test 4: Stop scheduler")
scheduler.stop()
# Final stats
print("\n📊 Final Statistics:")
final_stats = scheduler.get_stats()
for key, value in final_stats.items():
print(f" {key}: {value}")
print("\n✅ All tests passed!")
print("=" * 60)
@@ -0,0 +1,14 @@
framework: praisonai
agents:
- name: "5 Second Timer"
role: "Timer Agent"
instructions: "Display the current time"
verbose: true
task: "Show current time"
schedule:
interval: "*/5s"
max_retries: 3
run_immediately: true
+14
View File
@@ -0,0 +1,14 @@
framework: praisonai
agents:
- name: "Counter Agent"
role: "Counter"
instructions: "Count from 1 to 5, incrementing by 1 each time. Just output the numbers clearly."
verbose: true
task: "Print numbers 1, 2, 3, 4, 5 (one number per line)"
schedule:
interval: "*/5s"
max_retries: 3
run_immediately: true
@@ -0,0 +1,16 @@
framework: praisonai
agents:
- name: "Counter Agent with Limits"
role: "Counter"
instructions: "Count from 1 to 5, incrementing by 1 each time. Just output the numbers clearly."
verbose: true
task: "Print numbers 1, 2, 3, 4, 5 (one number per line)"
schedule:
interval: "*/5s" # Run every 5 seconds
max_retries: 3 # Retry up to 3 times on failure
run_immediately: true # Run once immediately on start
timeout: 30 # Max 30 seconds per execution
max_cost: 0.01 # Stop after spending $0.01 USD
@@ -0,0 +1,15 @@
framework: praisonai
agents:
- name: "Budget Test Agent"
role: "Tester"
instructions: "Count from 1 to 3"
verbose: true
task: "Print numbers 1, 2, 3"
schedule:
interval: "*/3s"
max_retries: 2
run_immediately: true
# No max_cost specified - should default to $1.00
+73
View File
@@ -0,0 +1,73 @@
"""
Basic Agent Skills Usage Example
This example demonstrates how to use Agent Skills with PraisonAI Agents.
Skills are modular capabilities that can be loaded and used by agents.
"""
from praisonaiagents import Agent, SkillManager
# Example 1: Using SkillManager directly
print("=" * 50)
print("Example 1: Using SkillManager directly")
print("=" * 50)
# Create a skill manager
manager = SkillManager()
# Discover skills from a directory
# This scans the directory for subdirectories containing SKILL.md files
skill_count = manager.discover(["./pdf-processing"], include_defaults=False)
print(f"Discovered {skill_count} skill(s)")
# List available skills
for skill in manager:
print(f" - {skill.properties.name}: {skill.properties.description[:50]}...")
# Generate prompt XML for system prompt injection
prompt_xml = manager.to_prompt()
print("\nGenerated prompt XML:")
print(prompt_xml)
# Example 2: Using skills with an Agent
print("\n" + "=" * 50)
print("Example 2: Using skills with an Agent")
print("=" * 50)
# Create an agent with skills
# Skills are lazy-loaded only when accessed
agent = Agent(
name="PDF Assistant",
instructions="You are a helpful assistant that can process PDF documents.",
skills=["./pdf-processing"], # Direct skill paths
)
# Access the skill manager through the agent
if agent.skill_manager:
print(f"Agent has {len(agent.skill_manager)} skill(s) loaded")
# Get the skills prompt for system prompt injection
skills_prompt = agent.get_skills_prompt()
print("\nAgent skills prompt:")
print(skills_prompt)
# Example 3: Discovering skills from multiple directories
print("\n" + "=" * 50)
print("Example 3: Using skills_dirs for discovery")
print("=" * 50)
# Create an agent that discovers skills from directories
agent_with_discovery = Agent(
name="Multi-Skill Agent",
instructions="You are a versatile assistant with multiple skills.",
skills_dirs=["./"], # Scan current directory for skill subdirectories
)
if agent_with_discovery.skill_manager:
print(f"Discovered {len(agent_with_discovery.skill_manager)} skill(s)")
for name in agent_with_discovery.skill_manager.skill_names:
print(f" - {name}")
print("\n" + "=" * 50)
print("Examples complete!")
print("=" * 50)
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Example: Creating Skills with PraisonAI CLI
This example demonstrates how to create Agent Skills using the CLI.
"""
import subprocess
import tempfile
import os
from pathlib import Path
def example_create_skill_with_template():
"""Create a skill using template mode (no API key required)."""
print("=" * 60)
print("Example 1: Create skill with template")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmpdir:
result = subprocess.run([
"praisonai", "skills", "create",
"--name", "my-template-skill",
"--description", "A skill for processing text files",
"--author", "example-team",
"--license", "MIT",
"--template",
"--output-dir", tmpdir
], capture_output=True, text=True)
print(result.stdout)
if result.returncode == 0:
skill_md = Path(tmpdir) / "my-template-skill" / "SKILL.md"
print(f"\nGenerated SKILL.md:\n{skill_md.read_text()[:500]}...")
else:
print(f"Error: {result.stderr}")
def example_create_skill_with_ai():
"""Create a skill using AI generation (requires API key)."""
print("\n" + "=" * 60)
print("Example 2: Create skill with AI generation")
print("=" * 60)
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping: OPENAI_API_KEY not set")
return
with tempfile.TemporaryDirectory() as tmpdir:
result = subprocess.run([
"praisonai", "skills", "create",
"--name", "csv-analyzer",
"--description", "Analyze CSV files and generate statistical reports",
"--output-dir", tmpdir
], capture_output=True, text=True)
print(result.stdout)
if result.returncode == 0:
skill_md = Path(tmpdir) / "csv-analyzer" / "SKILL.md"
print(f"\nGenerated SKILL.md:\n{skill_md.read_text()[:800]}...")
# Check if script was generated
script_path = Path(tmpdir) / "csv-analyzer" / "scripts" / "skill.py"
if script_path.exists():
print(f"\nGenerated skill.py:\n{script_path.read_text()[:500]}...")
def example_create_skill_with_script():
"""Create a skill with auto-generated Python script."""
print("\n" + "=" * 60)
print("Example 3: Create skill with script")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmpdir:
result = subprocess.run([
"praisonai", "skills", "create",
"--name", "data-processor",
"--description", "Process and transform data files",
"--template",
"--script",
"--output-dir", tmpdir
], capture_output=True, text=True)
print(result.stdout)
if result.returncode == 0:
script_path = Path(tmpdir) / "data-processor" / "scripts" / "skill.py"
if script_path.exists():
print(f"\nGenerated skill.py:\n{script_path.read_text()}")
if __name__ == "__main__":
example_create_skill_with_template()
example_create_skill_with_ai()
example_create_skill_with_script()
+163
View File
@@ -0,0 +1,163 @@
"""
Custom Skill Creation Example
This example demonstrates how to programmatically create and validate
custom Agent Skills using PraisonAI Agents.
"""
import tempfile
from pathlib import Path
from praisonaiagents.skills import (
SkillManager,
SkillLoader,
validate,
read_properties,
)
def create_custom_skill(
name: str,
description: str,
instructions: str,
base_dir: str = None
) -> Path:
"""Create a custom skill programmatically.
Args:
name: Skill name (kebab-case)
description: What the skill does
instructions: Markdown instructions for the skill
base_dir: Directory to create skill in (default: temp dir)
Returns:
Path to the created skill directory
"""
if base_dir is None:
base_dir = tempfile.mkdtemp()
skill_dir = Path(base_dir) / name
skill_dir.mkdir(parents=True, exist_ok=True)
# Create SKILL.md
skill_md_content = f"""---
name: {name}
description: {description}
license: Apache-2.0
metadata:
author: custom
version: "1.0"
---
{instructions}
"""
(skill_dir / "SKILL.md").write_text(skill_md_content)
# Create optional directories
(skill_dir / "scripts").mkdir(exist_ok=True)
(skill_dir / "references").mkdir(exist_ok=True)
(skill_dir / "assets").mkdir(exist_ok=True)
return skill_dir
def main():
print("=" * 60)
print("Custom Skill Creation Example")
print("=" * 60)
# Create a custom skill
print("\n1. Creating a custom skill...")
skill_path = create_custom_skill(
name="code-review",
description="Review code for best practices, bugs, and improvements. Use when asked to review or analyze code quality.",
instructions="""
# Code Review Skill
## Overview
This skill enables thorough code review with focus on:
- Code quality and readability
- Potential bugs and edge cases
- Performance considerations
- Security vulnerabilities
- Best practices adherence
## Instructions
When reviewing code:
1. **Read the code carefully** - Understand the purpose and flow
2. **Check for bugs** - Look for null checks, edge cases, error handling
3. **Evaluate readability** - Variable names, comments, structure
4. **Consider performance** - Time/space complexity, unnecessary operations
5. **Security review** - Input validation, injection risks, data exposure
6. **Suggest improvements** - Provide actionable recommendations
## Output Format
Structure your review as:
- Summary: Brief overview of the code
- Issues: List of problems found (severity: high/medium/low)
- Suggestions: Recommended improvements
- Positive aspects: What's done well
"""
)
print(f" Created skill at: {skill_path}")
# Validate the skill
print("\n2. Validating the skill...")
errors = validate(skill_path)
if errors:
print(" Validation errors:")
for error in errors:
print(f" - {error}")
else:
print(" ✓ Skill is valid!")
# Load and inspect the skill
print("\n3. Loading skill properties...")
props = read_properties(skill_path)
print(f" Name: {props.name}")
print(f" Description: {props.description[:60]}...")
print(f" License: {props.license}")
# Use with SkillLoader for progressive loading
print("\n4. Using SkillLoader for progressive disclosure...")
loader = SkillLoader()
# Level 1: Load metadata only
skill = loader.load_metadata(str(skill_path))
print(f" Level 1 - Metadata loaded: {skill.properties.name}")
print(f" Instructions loaded: {skill.is_activated}")
# Level 2: Activate (load instructions)
loader.activate(skill)
print(f" Level 2 - Activated: {skill.is_activated}")
print(f" Instructions preview: {skill.instructions[:100]}...")
# Use with SkillManager
print("\n5. Using with SkillManager...")
manager = SkillManager()
manager.add_skill(str(skill_path))
# Generate prompt XML
prompt = manager.to_prompt()
print(" Generated prompt XML:")
print(prompt)
# Clean up
print("\n6. Cleanup...")
import shutil
shutil.rmtree(skill_path.parent)
print(" Temporary files removed.")
print("\n" + "=" * 60)
print("Example complete!")
print("=" * 60)
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Example: Multi-Agent CLI Usage
This example demonstrates how to use the praisonai agents command
to define and run multiple agents from the command line.
"""
import subprocess
import os
def example_single_agent():
"""Run a single agent with a simple task."""
print("=" * 60)
print("Example 1: Single Agent")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "assistant:Helper",
"--task", "What is the capital of France?"
], capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print(f"Error: {result.stderr}")
def example_agent_with_tools():
"""Run an agent with tools."""
print("\n" + "=" * 60)
print("Example 2: Agent with Tools")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "researcher:Research Analyst:internet_search",
"--task", "Find the latest news about renewable energy"
], capture_output=True, text=True)
print(result.stdout)
def example_multiple_agents_sequential():
"""Run multiple agents in sequential mode."""
print("\n" + "=" * 60)
print("Example 3: Multiple Agents (Sequential)")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "researcher:Research Analyst:internet_search",
"--agent", "writer:Content Writer:write_file",
"--task", "Research AI trends and write a brief summary"
], capture_output=True, text=True)
print(result.stdout)
def example_multiple_agents_parallel():
"""Run multiple agents in parallel mode."""
print("\n" + "=" * 60)
print("Example 4: Multiple Agents (Parallel)")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "analyst1:Market Analyst",
"--agent", "analyst2:Tech Analyst",
"--process", "parallel",
"--task", "Analyze the current state of AI industry"
], capture_output=True, text=True)
print(result.stdout)
def example_with_custom_llm():
"""Run agents with a custom LLM model."""
print("\n" + "=" * 60)
print("Example 5: Custom LLM Model")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "coder:Developer:execute_command",
"--llm", "gpt-4o-mini",
"--task", "Write a simple hello world Python script"
], capture_output=True, text=True)
print(result.stdout)
def example_with_instructions():
"""Run agents with additional instructions."""
print("\n" + "=" * 60)
print("Example 6: With Additional Instructions")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "run",
"--agent", "analyst:Data Analyst",
"--instructions", "Be concise and provide only key insights",
"--task", "Explain the benefits of using AI in healthcare"
], capture_output=True, text=True)
print(result.stdout)
def example_list_tools():
"""List available tools for agents."""
print("\n" + "=" * 60)
print("Example 7: List Available Tools")
print("=" * 60)
result = subprocess.run([
"praisonai", "agents", "list"
], capture_output=True, text=True)
print(result.stdout)
if __name__ == "__main__":
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Warning: OPENAI_API_KEY not set. Some examples may fail.")
print()
# Run examples
example_list_tools()
example_single_agent()
# Uncomment to run more examples:
# example_agent_with_tools()
# example_multiple_agents_sequential()
# example_multiple_agents_parallel()
# example_with_custom_llm()
# example_with_instructions()
+38
View File
@@ -0,0 +1,38 @@
---
name: pdf-processing
description: Process and extract information from PDF documents. Use this skill when the user asks to read, analyze, or extract data from PDF files.
license: Apache-2.0
compatibility: Works with PraisonAI Agents
metadata:
author: praisonai
version: "1.0"
---
# PDF Processing Skill
## Overview
This skill enables agents to process PDF documents, extract text content, and analyze the information within them.
## When to Use
Activate this skill when:
- User asks to read or analyze a PDF file
- User needs to extract text from a PDF
- User wants to summarize a PDF document
- User needs to search for information within a PDF
## Instructions
1. First, verify the PDF file exists at the specified path
2. Use appropriate tools to read the PDF content
3. Extract text while preserving structure where possible
4. For large PDFs, process in chunks to manage context
5. Summarize or analyze based on user's specific request
## Best Practices
- Always confirm the file path before processing
- Handle encrypted PDFs gracefully with appropriate error messages
- For scanned PDFs, note that OCR may be required
- Preserve important formatting like tables and lists
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Basic Thinking Budgets Example for PraisonAI Agents.
This example demonstrates how to use thinking budgets to:
1. Configure token budgets for extended thinking
2. Use predefined budget levels
3. Track thinking usage
4. Adapt budgets based on complexity
Usage:
python basic_thinking.py
"""
from praisonaiagents.thinking import (
ThinkingBudget, ThinkingConfig, ThinkingUsage, ThinkingTracker
)
from praisonaiagents.thinking.budget import BudgetLevel
def main():
print("=" * 60)
print("Thinking Budgets Demo")
print("=" * 60)
# ==========================================================================
# Budget Levels
# ==========================================================================
print("\n--- Budget Levels ---")
levels = [
("Minimal", ThinkingBudget.minimal()),
("Low", ThinkingBudget.low()),
("Medium", ThinkingBudget.medium()),
("High", ThinkingBudget.high()),
("Maximum", ThinkingBudget.maximum()),
]
for name, budget in levels:
print(f" {name}: {budget.max_tokens:,} tokens")
# ==========================================================================
# Custom Budget
# ==========================================================================
print("\n--- Custom Budget ---")
custom_budget = ThinkingBudget(
max_tokens=12000,
min_tokens=2000,
adaptive=True,
max_time_seconds=120.0
)
print(f" Max tokens: {custom_budget.max_tokens:,}")
print(f" Min tokens: {custom_budget.min_tokens:,}")
print(f" Adaptive: {custom_budget.adaptive}")
print(f" Time limit: {custom_budget.max_time_seconds}s")
# ==========================================================================
# Complexity-Based Scaling
# ==========================================================================
print("\n--- Complexity-Based Token Allocation ---")
budget = ThinkingBudget(
max_tokens=16000,
min_tokens=2000,
adaptive=True
)
complexities = [0.0, 0.25, 0.5, 0.75, 1.0]
for complexity in complexities:
tokens = budget.get_tokens_for_complexity(complexity)
bar = "█" * int(tokens / 1000)
print(f" Complexity {complexity:.2f}: {tokens:,} tokens {bar}")
# ==========================================================================
# Usage Tracking
# ==========================================================================
print("\n--- Usage Tracking ---")
usage = ThinkingUsage(
budget_tokens=8000,
tokens_used=5000,
time_seconds=30.0,
budget_time=60.0
)
print(f" Tokens used: {usage.tokens_used:,} / {usage.budget_tokens:,}")
print(f" Tokens remaining: {usage.tokens_remaining:,}")
print(f" Utilization: {usage.token_utilization:.1%}")
print(f" Over budget: {usage.is_over_budget}")
print(f" Time remaining: {usage.time_remaining}s")
# ==========================================================================
# Session Tracking
# ==========================================================================
print("\n--- Session Tracking ---")
tracker = ThinkingTracker()
# Simulate multiple thinking sessions
sessions = [
(8000, 4000, 20.0),
(8000, 6000, 35.0),
(16000, 12000, 60.0),
(8000, 9000, 45.0), # Over budget
]
for budget_tokens, tokens_used, time_seconds in sessions:
session = tracker.start_session(budget_tokens=budget_tokens)
tracker.end_session(session, tokens_used=tokens_used, time_seconds=time_seconds)
summary = tracker.get_summary()
print(f" Total sessions: {summary['session_count']}")
print(f" Total tokens used: {summary['total_tokens_used']:,}")
print(f" Average tokens/session: {summary['average_tokens_per_session']:,.0f}")
print(f" Average utilization: {summary['average_utilization']:.1%}")
print(f" Over-budget sessions: {summary['over_budget_count']}")
# ==========================================================================
# Serialization
# ==========================================================================
print("\n--- Serialization ---")
budget = ThinkingBudget.from_level(BudgetLevel.HIGH)
data = budget.to_dict()
print(f" Serialized: level={data['level']}, max_tokens={data['max_tokens']}")
restored = ThinkingBudget.from_dict(data)
print(f" Restored: {restored.max_tokens:,} tokens")
print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -3,7 +3,7 @@ Praison AI Agents - A package for hierarchical AI agent task execution
"""
# Apply warning patch BEFORE any imports to intercept warnings at the source
from . import _warning_patch
from . import _warning_patch # noqa: F401
# Import centralized logging configuration FIRST
from . import _logging
@@ -78,6 +78,19 @@ def __getattr__(name):
elif name == "LineRange":
from praisonaiagents.context.fast import LineRange
return LineRange
# Agent Skills support (lazy loaded for zero performance impact)
elif name == "SkillManager":
from praisonaiagents.skills import SkillManager
return SkillManager
elif name == "SkillProperties":
from praisonaiagents.skills import SkillProperties
return SkillProperties
elif name == "SkillMetadata":
from praisonaiagents.skills import SkillMetadata
return SkillMetadata
elif name == "SkillLoader":
from praisonaiagents.skills import SkillLoader
return SkillLoader
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Planning mode support
@@ -289,6 +302,23 @@ __all__ = [
'ExpandResult'
]
# Add workflow exports
__all__.extend([
'Workflow',
'WorkflowStep',
'WorkflowContext',
'StepResult',
'Route',
'Parallel',
'Loop',
'Repeat',
'route',
'parallel',
'loop',
'repeat',
'Pipeline'
])
# Add MCP to __all__ if available
if _mcp_available:
__all__.append('MCP')
@@ -305,6 +335,14 @@ __all__.extend([
'LineRange'
])
# Add Agent Skills exports (lazy loaded)
__all__.extend([
'SkillManager',
'SkillProperties',
'SkillMetadata',
'SkillLoader'
])
# AG-UI support (optional, lazy loaded)
try:
from praisonaiagents.ui.agui import AGUI
@@ -8,9 +8,10 @@ import functools
import sys
# Apply aggressive warning filters first
warnings.filterwarnings("ignore", message=".*There is no current event loop.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*Pydantic serializer warnings.*")
warnings.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*")
warnings.filterwarnings("ignore", message=".*Expected 9 fields but got.*")
warnings.filterwarnings("ignore", message=".*Expected \\d+ fields but got.*")
warnings.filterwarnings("ignore", message=".*Expected `StreamingChoices` but got.*")
warnings.filterwarnings("ignore", message=".*serialized value may not be as expected.*")
warnings.filterwarnings("ignore", message=".*Use 'content=<...>' to upload raw bytes/text content.*")
@@ -27,11 +28,12 @@ SUPPRESSED_PATTERNS = [
"The `dict` method is deprecated; use `model_dump` instead",
"Pydantic serializer warnings",
"PydanticSerializationUnexpectedValue",
"Expected 9 fields but got 5 for type `Message`",
"Expected", # Catches all "Expected N fields but got M" patterns
"Expected `StreamingChoices` but got `Choices`",
"serialized value may not be as expected",
"Mixing V1 models and V2 models",
"Please upgrade `Settings` to V2"
"Please upgrade `Settings` to V2",
"There is no current event loop"
]
@functools.wraps(_original_warn)
@@ -43,7 +45,7 @@ def _patched_warn(message, category=None, stacklevel=1, source=None):
if pattern in msg_str:
return
if category == UserWarning and "pydantic" in msg_str.lower():
if category is UserWarning and "pydantic" in msg_str.lower():
return
_original_warn(message, category, stacklevel, source)
@@ -57,7 +59,7 @@ def _patched_warn_explicit(message, category, filename, lineno, module=None, reg
if pattern in msg_str:
return
if category == UserWarning and "pydantic" in msg_str.lower():
if category is UserWarning and "pydantic" in msg_str.lower():
return
if module and "pydantic" in str(module):
@@ -250,7 +250,9 @@ class Agent:
fast_context_parallelism: int = 8,
fast_context_timeout: float = 30.0,
history_in_context: Optional[int] = None,
auto_save: Optional[str] = None
auto_save: Optional[str] = None,
skills: Optional[List[str]] = None,
skills_dirs: Optional[List[str]] = None
):
"""Initialize an Agent instance.
@@ -607,6 +609,12 @@ Your Goal: {self.goal}
self.fast_context_timeout = fast_context_timeout
self._fast_context_instance = None # Lazy loaded
# Agent Skills configuration (lazy loaded for zero performance impact)
self._skills = skills
self._skills_dirs = skills_dirs
self._skill_manager = None # Lazy loaded
self._skills_initialized = False
@property
def console(self):
"""Lazily initialize Rich Console only when needed."""
@@ -626,6 +634,72 @@ Your Goal: {self.goal}
def fast_context_path(self, value):
self._fast_context_path = value
@property
def skill_manager(self):
"""Lazily initialize SkillManager only when skills are accessed."""
if self._skill_manager is None and (self._skills or self._skills_dirs):
from ..skills import SkillManager
self._skill_manager = SkillManager()
# Add explicit skill paths
if self._skills:
for skill_path in self._skills:
self._skill_manager.add_skill(skill_path)
# Discover skills from directories
if self._skills_dirs:
self._skill_manager.discover(self._skills_dirs, include_defaults=False)
self._skills_initialized = True
# Auto-add skill execution tools if not already present
self._add_skill_tools()
return self._skill_manager
def _add_skill_tools(self):
"""Add tools required for skill execution (read_file, run_skill_script).
Uses lazy imports from praisonaiagents.tools to avoid performance impact
when skills are not used.
"""
# Check if tools already include required capabilities
tool_names = set()
for tool in self.tools:
if callable(tool) and hasattr(tool, '__name__'):
tool_names.add(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.add(tool.name)
# Add read_file if not present
if 'read_file' not in tool_names:
try:
from ..tools import read_file
self.tools.append(read_file)
logging.debug("Added read_file tool for skill support")
except ImportError:
logging.warning("Could not import read_file tool for skills")
# Add run_skill_script from skill_tools module
if 'run_skill_script' not in tool_names:
try:
from ..tools.skill_tools import create_skill_tools
# Create skill tools with current working directory
skill_tools = create_skill_tools()
self.tools.append(skill_tools.run_skill_script)
logging.debug("Added run_skill_script tool for skill support")
except ImportError as e:
logging.warning(f"Could not import skill_tools: {e}")
def get_skills_prompt(self) -> str:
"""Get the XML prompt for available skills.
Returns:
XML string with <available_skills> block, or empty string if no skills
"""
if self.skill_manager is None:
return ""
return self.skill_manager.to_prompt()
@property
def _openai_client(self):
"""Lazily initialize OpenAI client only when needed."""
@@ -1284,6 +1358,13 @@ Your Goal: {self.goal}"""
if self.verbose:
self._display_memory_info()
# Add skills prompt if skills are configured
if self._skills or self._skills_dirs:
skills_prompt = self.get_skills_prompt()
if skills_prompt:
system_prompt += f"\n\n## Available Skills\n{skills_prompt}"
system_prompt += "\n\nWhen a skill is relevant to the task, read its SKILL.md file to get detailed instructions. If the skill has scripts in its scripts/ directory, you can execute them using the execute_code or run_script tool."
# Add tool usage instructions if tools are available
# Use provided tools or fall back to self.tools
tools_to_use = tools if tools is not None else self.tools
@@ -0,0 +1,64 @@
"""
Background Agents Module for PraisonAI Agents.
Provides the ability to run agents in the background, allowing:
- Long-running tasks without blocking
- Task queuing and management
- Progress monitoring and notifications
- Graceful cancellation
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Background processing only when explicitly started
- No overhead when not in use
Usage:
from praisonaiagents.background import BackgroundRunner, BackgroundTask
# Create a background runner
runner = BackgroundRunner()
# Submit a task
task = runner.submit(
agent=my_agent,
prompt="Research AI trends",
callback=on_complete
)
# Check status
print(task.status) # "running", "completed", "failed"
# Wait for completion
result = await task.wait()
"""
__all__ = [
# Core classes
"BackgroundRunner",
"BackgroundTask",
# Status enum
"TaskStatus",
# Configuration
"BackgroundConfig",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "BackgroundRunner":
from .runner import BackgroundRunner
return BackgroundRunner
if name == "BackgroundTask":
from .task import BackgroundTask
return BackgroundTask
if name == "TaskStatus":
from .task import TaskStatus
return TaskStatus
if name == "BackgroundConfig":
from .config import BackgroundConfig
return BackgroundConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,17 @@
"""
Background Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class BackgroundConfig:
"""Configuration for background task execution."""
max_concurrent_tasks: int = 5
default_timeout: Optional[float] = None # None = no timeout
auto_cleanup: bool = True # Auto-remove completed tasks
cleanup_delay: float = 300.0 # Seconds to keep completed tasks
enable_notifications: bool = True
log_progress: bool = True
@@ -0,0 +1,314 @@
"""
Background Runner for PraisonAI Agents.
Manages background task execution.
"""
import asyncio
import logging
from typing import Optional, List, Dict, Any, Callable, Union
from datetime import datetime
from .task import BackgroundTask, TaskStatus
from .config import BackgroundConfig
logger = logging.getLogger(__name__)
class BackgroundRunner:
"""
Manages background task execution.
Provides:
- Task submission and queuing
- Concurrent execution management
- Progress monitoring
- Task cancellation
Example:
runner = BackgroundRunner()
# Submit a task
task = await runner.submit(
func=my_agent.start,
args=("Research AI trends",),
name="research_task"
)
# Check status
print(task.status)
# Wait for result
result = await task.wait()
"""
def __init__(self, config: Optional[BackgroundConfig] = None):
"""
Initialize the background runner.
Args:
config: Configuration for background execution
"""
self.config = config or BackgroundConfig()
self._tasks: Dict[str, BackgroundTask] = {}
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
self._running = False
self._cleanup_task: Optional[asyncio.Task] = None
@property
def tasks(self) -> List[BackgroundTask]:
"""Get all tasks."""
return list(self._tasks.values())
@property
def running_tasks(self) -> List[BackgroundTask]:
"""Get currently running tasks."""
return [t for t in self._tasks.values() if t.is_running]
@property
def pending_tasks(self) -> List[BackgroundTask]:
"""Get pending tasks."""
return [t for t in self._tasks.values() if t.status == TaskStatus.PENDING]
async def submit(
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,
on_progress: Optional[Callable[[BackgroundTask], None]] = None
) -> BackgroundTask:
"""
Submit a task for background execution.
Args:
func: Function to execute (can be sync or async)
args: Positional arguments for the function
kwargs: Keyword arguments for the function
name: Optional task name
timeout: Optional timeout in seconds
on_complete: Callback when task completes
on_progress: Callback for progress updates
Returns:
BackgroundTask object for tracking
"""
kwargs = kwargs or {}
task = BackgroundTask(
name=name or func.__name__,
metadata={
"func_name": func.__name__,
"timeout": timeout or self.config.default_timeout
}
)
self._tasks[task.id] = task
# Create the execution coroutine
async def execute():
async with self._semaphore:
task.start()
logger.info(f"Background task started: {task.name} ({task.id})")
try:
# Check if function is async
if asyncio.iscoroutinefunction(func):
result = await asyncio.wait_for(
func(*args, **kwargs),
timeout=timeout or self.config.default_timeout
)
else:
# Run sync function in thread pool
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(None, lambda: func(*args, **kwargs)),
timeout=timeout or self.config.default_timeout
)
task.complete(result)
logger.info(f"Background task completed: {task.name} ({task.id})")
if on_complete:
try:
on_complete(task)
except Exception as e:
logger.warning(f"on_complete callback error: {e}")
return result
except asyncio.TimeoutError:
task.fail(f"Task timed out after {timeout}s")
logger.warning(f"Background task timed out: {task.name}")
except asyncio.CancelledError:
task.cancel()
logger.info(f"Background task cancelled: {task.name}")
raise
except Exception as e:
task.fail(str(e))
logger.error(f"Background task failed: {task.name} - {e}")
if on_complete:
try:
on_complete(task)
except Exception as cb_error:
logger.warning(f"on_complete callback error: {cb_error}")
# Create and store the future
task._future = asyncio.create_task(execute())
return task
async def submit_agent(
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 for background execution.
Args:
agent: Agent instance to 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 object for tracking
"""
task_name = name or f"agent_{getattr(agent, 'name', 'unknown')}"
# Determine the method to call
if hasattr(agent, 'start'):
func = agent.start
elif hasattr(agent, 'chat'):
func = agent.chat
elif hasattr(agent, 'run'):
func = agent.run
else:
raise ValueError("Agent must have start(), chat(), or run() method")
return await self.submit(
func=func,
args=(prompt,),
name=task_name,
timeout=timeout,
on_complete=on_complete
)
def get_task(self, task_id: str) -> Optional[BackgroundTask]:
"""Get a task by ID."""
return self._tasks.get(task_id)
async def cancel_task(self, task_id: str) -> bool:
"""
Cancel a task.
Args:
task_id: ID of task to cancel
Returns:
True if cancelled, False if not found or already completed
"""
task = self._tasks.get(task_id)
if task is None:
return False
if task.is_completed:
return False
task.cancel()
if task._future and not task._future.done():
task._future.cancel()
return True
async def cancel_all(self):
"""Cancel all running tasks."""
for task in self.running_tasks:
await self.cancel_task(task.id)
async def wait_all(self, timeout: Optional[float] = None) -> List[BackgroundTask]:
"""
Wait for all tasks to complete.
Args:
timeout: Maximum time to wait
Returns:
List of completed tasks
"""
futures = [t._future for t in self._tasks.values() if t._future]
if not futures:
return list(self._tasks.values())
try:
await asyncio.wait_for(
asyncio.gather(*futures, return_exceptions=True),
timeout=timeout
)
except asyncio.TimeoutError:
pass
return list(self._tasks.values())
def clear_completed(self):
"""Remove completed tasks from tracking."""
completed_ids = [
task_id for task_id, task in self._tasks.items()
if task.is_completed
]
for task_id in completed_ids:
del self._tasks[task_id]
def list_tasks(self, status: Optional[TaskStatus] = None) -> List[Dict[str, Any]]:
"""
List tasks with optional status filter.
Args:
status: Optional status to filter by
Returns:
List of task dictionaries
"""
tasks = self._tasks.values()
if status:
tasks = [t for t in tasks if t.status == status]
return [t.to_dict() for t in tasks]
async def start_cleanup_loop(self):
"""Start the automatic cleanup loop."""
if self._cleanup_task is not None:
return
async def cleanup_loop():
while True:
await asyncio.sleep(self.config.cleanup_delay)
if self.config.auto_cleanup:
self.clear_completed()
self._cleanup_task = asyncio.create_task(cleanup_loop())
async def stop(self):
"""Stop the runner and cancel all tasks."""
await self.cancel_all()
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
@@ -0,0 +1,154 @@
"""
Background Task for PraisonAI Agents.
Represents a task running in the background.
"""
import uuid
import asyncio
import logging
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Any, Callable, Dict
from datetime import datetime
logger = logging.getLogger(__name__)
class TaskStatus(str, Enum):
"""Status of a background task."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass
class BackgroundTask:
"""
Represents a background task.
Tracks the status, progress, and result of a task running
in the background.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
name: str = ""
status: TaskStatus = TaskStatus.PENDING
progress: float = 0.0 # 0.0 to 1.0
result: Any = None
error: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# Internal
_future: Optional[asyncio.Future] = field(default=None, repr=False)
_cancel_event: Optional[asyncio.Event] = field(default=None, repr=False)
def __post_init__(self):
self._cancel_event = asyncio.Event()
@property
def is_running(self) -> bool:
"""Check if task is currently running."""
return self.status == TaskStatus.RUNNING
@property
def is_completed(self) -> bool:
"""Check if task has completed (success or failure)."""
return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED)
@property
def is_successful(self) -> bool:
"""Check if task completed successfully."""
return self.status == TaskStatus.COMPLETED
@property
def duration_seconds(self) -> Optional[float]:
"""Get task duration in seconds."""
if self.started_at is None:
return None
end_time = self.completed_at or datetime.now()
return (end_time - self.started_at).total_seconds()
def start(self):
"""Mark task as started."""
self.status = TaskStatus.RUNNING
self.started_at = datetime.now()
def complete(self, result: Any = None):
"""Mark task as completed."""
self.status = TaskStatus.COMPLETED
self.result = result
self.progress = 1.0
self.completed_at = datetime.now()
def fail(self, error: str):
"""Mark task as failed."""
self.status = TaskStatus.FAILED
self.error = error
self.completed_at = datetime.now()
def cancel(self):
"""Request task cancellation."""
if self._cancel_event:
self._cancel_event.set()
self.status = TaskStatus.CANCELLED
self.completed_at = datetime.now()
def update_progress(self, progress: float, message: Optional[str] = None):
"""Update task progress."""
self.progress = min(max(progress, 0.0), 1.0)
if message:
self.metadata["last_message"] = message
async def wait(self, timeout: Optional[float] = None) -> Any:
"""
Wait for task completion.
Args:
timeout: Maximum time to wait in seconds
Returns:
Task result
Raises:
asyncio.TimeoutError: If timeout exceeded
RuntimeError: If task failed
"""
if self._future is None:
if self.is_completed:
if self.status == TaskStatus.FAILED:
raise RuntimeError(self.error)
return self.result
raise RuntimeError("Task has no associated future")
try:
result = await asyncio.wait_for(self._future, timeout=timeout)
return result
except asyncio.TimeoutError:
raise
except Exception as e:
raise RuntimeError(str(e))
def should_cancel(self) -> bool:
"""Check if cancellation was requested."""
return self._cancel_event is not None and self._cancel_event.is_set()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"name": self.name,
"status": self.status.value,
"progress": self.progress,
"result": str(self.result) if self.result else None,
"error": self.error,
"created_at": self.created_at.isoformat(),
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"duration_seconds": self.duration_seconds,
"metadata": self.metadata
}
@@ -0,0 +1,65 @@
"""
Checkpoints Module for PraisonAI Agents.
Provides file-level checkpointing using a shadow git repository to track
and restore file changes made by agents. This enables:
- Automatic checkpointing before file modifications
- Rewind to any previous checkpoint
- Diff between checkpoints
- Restore files and conversation state together
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Checkpoints only created when enabled
- No overhead when checkpoints are disabled
Usage:
from praisonaiagents.checkpoints import CheckpointService
# Create checkpoint service
service = CheckpointService(
workspace_dir="/path/to/project",
storage_dir="~/.praison/checkpoints"
)
# Initialize shadow git
await service.initialize()
# Save a checkpoint
checkpoint_id = await service.save("Before refactoring")
# Restore to a checkpoint
await service.restore(checkpoint_id)
# Get diff between checkpoints
diff = await service.diff(from_id, to_id)
"""
__all__ = [
# Core service
"CheckpointService",
# Data types
"Checkpoint",
"CheckpointDiff",
"CheckpointConfig",
# Events
"CheckpointEvent",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "CheckpointService":
from .service import CheckpointService
return CheckpointService
if name in ("Checkpoint", "CheckpointDiff", "CheckpointConfig"):
from .types import Checkpoint, CheckpointDiff, CheckpointConfig
return locals()[name]
if name == "CheckpointEvent":
from .types import CheckpointEvent
return CheckpointEvent
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,497 @@
"""
Checkpoint Service for PraisonAI Agents.
Implements shadow git repository for file-level checkpointing.
"""
import os
import asyncio
import logging
import shutil
from typing import Optional, List, Dict, Any, Callable
from datetime import datetime
from pathlib import Path
from .types import (
CheckpointConfig, Checkpoint, CheckpointDiff, FileDiff,
CheckpointResult, CheckpointEvent
)
logger = logging.getLogger(__name__)
# Protected paths that should never be checkpointed
PROTECTED_PATHS = [
os.path.expanduser("~"),
os.path.expanduser("~/Desktop"),
os.path.expanduser("~/Documents"),
os.path.expanduser("~/Downloads"),
"/",
"/tmp",
"/var",
"/etc",
]
class CheckpointService:
"""
Shadow Git Checkpoint Service.
Uses a separate git repository to track file changes in the workspace,
enabling checkpoint creation, restoration, and diffing without
interfering with the workspace's own git repository.
Example:
service = CheckpointService(
workspace_dir="/path/to/project",
storage_dir="~/.praison/checkpoints"
)
await service.initialize()
# Save checkpoint before making changes
result = await service.save("Before refactoring")
# Make changes...
# Restore if needed
await service.restore(result.checkpoint.id)
"""
def __init__(
self,
workspace_dir: str,
storage_dir: Optional[str] = None,
enabled: bool = True,
auto_checkpoint: bool = True,
max_checkpoints: int = 100
):
"""
Initialize the checkpoint service.
Args:
workspace_dir: Directory to track
storage_dir: Where to store checkpoint data
enabled: Whether checkpoints are enabled
auto_checkpoint: Auto-checkpoint before file modifications
max_checkpoints: Maximum checkpoints to keep
"""
self.config = CheckpointConfig(
workspace_dir=workspace_dir,
storage_dir=storage_dir,
enabled=enabled,
auto_checkpoint=auto_checkpoint,
max_checkpoints=max_checkpoints
)
self._initialized = False
self._checkpoints: List[Checkpoint] = []
self._event_handlers: Dict[CheckpointEvent, List[Callable]] = {
event: [] for event in CheckpointEvent
}
@property
def workspace_dir(self) -> str:
"""Get the workspace directory."""
return self.config.workspace_dir
@property
def checkpoint_dir(self) -> str:
"""Get the checkpoint storage directory."""
return self.config.get_checkpoint_dir()
@property
def git_dir(self) -> str:
"""Get the shadow git directory."""
return os.path.join(self.checkpoint_dir, ".git")
@property
def is_initialized(self) -> bool:
"""Check if the service is initialized."""
return self._initialized
def on(self, event: CheckpointEvent, handler: Callable):
"""Register an event handler."""
self._event_handlers[event].append(handler)
def _emit(self, event: CheckpointEvent, data: Any = None):
"""Emit an event to all handlers."""
for handler in self._event_handlers[event]:
try:
handler(data)
except Exception as e:
logger.warning(f"Event handler error: {e}")
async def initialize(self) -> bool:
"""
Initialize the shadow git repository.
Returns:
True if initialization succeeded
"""
if not self.config.enabled:
return False
# Check for protected paths
workspace_real = os.path.realpath(self.config.workspace_dir)
for protected in PROTECTED_PATHS:
if workspace_real == os.path.realpath(protected):
logger.warning(f"Cannot checkpoint protected path: {workspace_real}")
return False
# Check if workspace exists
if not os.path.isdir(self.config.workspace_dir):
logger.error(f"Workspace does not exist: {self.config.workspace_dir}")
return False
# Create checkpoint directory
os.makedirs(self.checkpoint_dir, exist_ok=True)
# Initialize shadow git if needed
if not os.path.exists(self.git_dir):
success = await self._init_shadow_git()
if not success:
return False
# Load existing checkpoints
await self._load_checkpoints()
self._initialized = True
self._emit(CheckpointEvent.INITIALIZED, {"workspace": self.config.workspace_dir})
return True
async def _init_shadow_git(self) -> bool:
"""Initialize the shadow git repository."""
try:
# Initialize git repo
await self._run_git("init")
# Configure git
await self._run_git("config", "user.name", "PraisonAI Checkpoints")
await self._run_git("config", "user.email", "[email protected]")
# Set worktree to workspace
await self._run_git("config", "core.worktree", self.config.workspace_dir)
# Create exclude file
exclude_dir = os.path.join(self.git_dir, "info")
os.makedirs(exclude_dir, exist_ok=True)
exclude_file = os.path.join(exclude_dir, "exclude")
with open(exclude_file, "w") as f:
for pattern in self.config.exclude_patterns:
f.write(f"{pattern}\n")
# Also exclude the checkpoint directory itself
f.write(f"{self.checkpoint_dir}\n")
# Create initial empty commit
await self._run_git("commit", "--allow-empty", "-m", "Initial checkpoint")
logger.info(f"Initialized shadow git at {self.checkpoint_dir}")
return True
except Exception as e:
logger.error(f"Failed to initialize shadow git: {e}")
return False
async def _run_git(self, *args: str) -> str:
"""Run a git command in the shadow repository."""
env = self._get_sanitized_env()
cmd = ["git", f"--git-dir={self.git_dir}", f"--work-tree={self.config.workspace_dir}"]
cmd.extend(args)
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=self.config.workspace_dir
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode().strip()
raise RuntimeError(f"Git command failed: {' '.join(args)}\n{error_msg}")
return stdout.decode().strip()
def _get_sanitized_env(self) -> Dict[str, str]:
"""Get environment with git variables sanitized."""
env = os.environ.copy()
# Remove git environment variables that could interfere
git_vars = [
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"
]
for var in git_vars:
env.pop(var, None)
return env
async def save(self, message: str, allow_empty: bool = False) -> CheckpointResult:
"""
Save a checkpoint.
Args:
message: Checkpoint message
allow_empty: Allow checkpoint even if no changes
Returns:
CheckpointResult with the created checkpoint
"""
if not self._initialized:
return CheckpointResult.fail("Service not initialized")
try:
# Stage all changes
await self._run_git("add", "-A")
# Check if there are changes
try:
await self._run_git("diff", "--cached", "--quiet")
has_changes = False
except RuntimeError:
has_changes = True
if not has_changes and not allow_empty:
return CheckpointResult.fail("No changes to checkpoint")
# Create commit
commit_args = ["commit", "-m", message]
if allow_empty:
commit_args.append("--allow-empty")
await self._run_git(*commit_args)
# Get commit info
commit_hash = await self._run_git("rev-parse", "HEAD")
timestamp = await self._run_git("log", "-1", "--format=%aI")
checkpoint = Checkpoint(
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp)
)
self._checkpoints.append(checkpoint)
# Prune old checkpoints if needed
await self._prune_checkpoints()
self._emit(CheckpointEvent.CHECKPOINT_CREATED, checkpoint)
logger.info(f"Created checkpoint: {checkpoint.short_id} - {message}")
return CheckpointResult.ok(checkpoint)
except Exception as e:
error_msg = str(e)
self._emit(CheckpointEvent.ERROR, {"error": error_msg})
return CheckpointResult.fail(error_msg)
async def restore(self, checkpoint_id: str) -> CheckpointResult:
"""
Restore workspace to a checkpoint.
Args:
checkpoint_id: Checkpoint ID (commit hash) to restore
Returns:
CheckpointResult indicating success/failure
"""
if not self._initialized:
return CheckpointResult.fail("Service not initialized")
try:
# Clean untracked files
await self._run_git("clean", "-f", "-d")
# Reset to checkpoint
await self._run_git("reset", "--hard", checkpoint_id)
# Get checkpoint info
message = await self._run_git("log", "-1", "--format=%s", checkpoint_id)
timestamp = await self._run_git("log", "-1", "--format=%aI", checkpoint_id)
checkpoint = Checkpoint(
id=checkpoint_id,
short_id=checkpoint_id[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp)
)
self._emit(CheckpointEvent.CHECKPOINT_RESTORED, checkpoint)
logger.info(f"Restored to checkpoint: {checkpoint.short_id}")
return CheckpointResult.ok(checkpoint)
except Exception as e:
error_msg = str(e)
self._emit(CheckpointEvent.ERROR, {"error": error_msg})
return CheckpointResult.fail(error_msg)
async def diff(
self,
from_id: Optional[str] = None,
to_id: Optional[str] = None
) -> CheckpointDiff:
"""
Get diff between checkpoints.
Args:
from_id: Starting checkpoint (default: previous checkpoint)
to_id: Ending checkpoint (default: current working directory)
Returns:
CheckpointDiff with file changes
"""
if not self._initialized:
return CheckpointDiff(from_checkpoint="", to_checkpoint="", files=[])
try:
# Default from_id to HEAD~1
if from_id is None:
try:
from_id = await self._run_git("rev-parse", "HEAD~1")
except RuntimeError:
from_id = await self._run_git("rev-parse", "HEAD")
# Build diff command
if to_id:
diff_output = await self._run_git(
"diff", "--stat", "--numstat", from_id, to_id
)
else:
# Diff against working directory
await self._run_git("add", "-A") # Stage to include untracked
diff_output = await self._run_git(
"diff", "--stat", "--numstat", "--cached", from_id
)
# Parse diff output
files = self._parse_diff_output(diff_output)
return CheckpointDiff(
from_checkpoint=from_id[:8] if from_id else "",
to_checkpoint=to_id[:8] if to_id else None,
files=files
)
except Exception as e:
logger.error(f"Failed to get diff: {e}")
return CheckpointDiff(from_checkpoint="", to_checkpoint="", files=[])
def _parse_diff_output(self, output: str) -> List[FileDiff]:
"""Parse git diff --numstat output."""
files = []
for line in output.strip().split("\n"):
if not line or "\t" not in line:
continue
parts = line.split("\t")
if len(parts) >= 3:
try:
additions = int(parts[0]) if parts[0] != "-" else 0
deletions = int(parts[1]) if parts[1] != "-" else 0
path = parts[2]
# Determine status
if additions > 0 and deletions == 0:
status = "added"
elif additions == 0 and deletions > 0:
status = "deleted"
else:
status = "modified"
files.append(FileDiff(
path=path,
absolute_path=os.path.join(self.config.workspace_dir, path),
status=status,
additions=additions,
deletions=deletions
))
except ValueError:
continue
return files
async def list_checkpoints(self, limit: int = 50) -> List[Checkpoint]:
"""
List all checkpoints.
Args:
limit: Maximum number of checkpoints to return
Returns:
List of checkpoints, newest first
"""
if not self._initialized:
return []
try:
# Get commit log
log_output = await self._run_git(
"log", f"-{limit}", "--format=%H|%s|%aI"
)
checkpoints = []
for line in log_output.strip().split("\n"):
if not line or "|" not in line:
continue
parts = line.split("|", 2)
if len(parts) >= 3:
checkpoints.append(Checkpoint(
id=parts[0],
short_id=parts[0][:8],
message=parts[1],
timestamp=datetime.fromisoformat(parts[2])
))
return checkpoints
except Exception as e:
logger.error(f"Failed to list checkpoints: {e}")
return []
async def _load_checkpoints(self):
"""Load existing checkpoints from git log."""
self._checkpoints = await self.list_checkpoints()
async def _prune_checkpoints(self):
"""Prune old checkpoints if over limit."""
if len(self._checkpoints) <= self.config.max_checkpoints:
return
# Keep only the most recent checkpoints
# Note: This doesn't actually delete git history, just our tracking
self._checkpoints = self._checkpoints[:self.config.max_checkpoints]
async def get_checkpoint(self, checkpoint_id: str) -> Optional[Checkpoint]:
"""Get a specific checkpoint by ID."""
for cp in self._checkpoints:
if cp.id.startswith(checkpoint_id) or cp.short_id == checkpoint_id:
return cp
return None
async def cleanup(self):
"""Clean up the checkpoint service."""
# Nothing to clean up currently
self._initialized = False
async def delete_all(self) -> bool:
"""Delete all checkpoint data for this workspace."""
try:
if os.path.exists(self.checkpoint_dir):
shutil.rmtree(self.checkpoint_dir)
self._checkpoints = []
self._initialized = False
return True
except Exception as e:
logger.error(f"Failed to delete checkpoints: {e}")
return False
@@ -0,0 +1,158 @@
"""
Checkpoint Types for PraisonAI Agents.
Defines the core types, enums, and dataclasses for the checkpoint system.
"""
import os
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from datetime import datetime
from pathlib import Path
class CheckpointEvent(str, Enum):
"""Events emitted by the checkpoint service."""
INITIALIZED = "initialized"
CHECKPOINT_CREATED = "checkpoint_created"
CHECKPOINT_RESTORED = "checkpoint_restored"
ERROR = "error"
@dataclass
class CheckpointConfig:
"""Configuration for the checkpoint service."""
workspace_dir: str
storage_dir: Optional[str] = None
enabled: bool = True
auto_checkpoint: bool = True # Auto-checkpoint before file modifications
max_checkpoints: int = 100 # Maximum checkpoints to keep
exclude_patterns: List[str] = field(default_factory=lambda: [
".git",
".praison",
"__pycache__",
"*.pyc",
".env",
"node_modules",
".venv",
"venv",
"*.log"
])
def __post_init__(self):
# Expand paths
self.workspace_dir = os.path.expanduser(self.workspace_dir)
if self.storage_dir:
self.storage_dir = os.path.expanduser(self.storage_dir)
else:
# Default storage in user's home directory
self.storage_dir = os.path.expanduser("~/.praison/checkpoints")
def get_checkpoint_dir(self) -> str:
"""Get the checkpoint directory for this workspace."""
# Hash the workspace path to create a unique directory
import hashlib
workspace_hash = hashlib.sha256(
self.workspace_dir.encode()
).hexdigest()[:12]
return os.path.join(self.storage_dir, workspace_hash)
@dataclass
class Checkpoint:
"""Represents a single checkpoint."""
id: str # Git commit hash
short_id: str # Short hash (8 chars)
message: str
timestamp: datetime
files_changed: int = 0
insertions: int = 0
deletions: int = 0
@classmethod
def from_git_commit(cls, commit_hash: str, message: str, timestamp: str) -> "Checkpoint":
"""Create a Checkpoint from git commit info."""
return cls(
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else timestamp
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"short_id": self.short_id,
"message": self.message,
"timestamp": self.timestamp.isoformat(),
"files_changed": self.files_changed,
"insertions": self.insertions,
"deletions": self.deletions
}
@dataclass
class FileDiff:
"""Represents a diff for a single file."""
path: str
absolute_path: str
status: str # added, modified, deleted
additions: int = 0
deletions: int = 0
before_content: Optional[str] = None
after_content: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"path": self.path,
"absolute_path": self.absolute_path,
"status": self.status,
"additions": self.additions,
"deletions": self.deletions
}
@dataclass
class CheckpointDiff:
"""Represents a diff between two checkpoints."""
from_checkpoint: str
to_checkpoint: Optional[str] # None means current working directory
files: List[FileDiff] = field(default_factory=list)
total_additions: int = 0
total_deletions: int = 0
def __post_init__(self):
# Calculate totals
self.total_additions = sum(f.additions for f in self.files)
self.total_deletions = sum(f.deletions for f in self.files)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"from_checkpoint": self.from_checkpoint,
"to_checkpoint": self.to_checkpoint,
"files": [f.to_dict() for f in self.files],
"total_additions": self.total_additions,
"total_deletions": self.total_deletions
}
@dataclass
class CheckpointResult:
"""Result of a checkpoint operation."""
success: bool
checkpoint: Optional[Checkpoint] = None
error: Optional[str] = None
@classmethod
def ok(cls, checkpoint: Checkpoint) -> "CheckpointResult":
"""Create a successful result."""
return cls(success=True, checkpoint=checkpoint)
@classmethod
def fail(cls, error: str) -> "CheckpointResult":
"""Create a failed result."""
return cls(success=False, error=error)
@@ -0,0 +1,57 @@
"""
Auto Context Compaction Module for PraisonAI Agents.
Provides automatic context management for long conversations:
- Message summarization
- Context window management
- Token counting and limits
- Intelligent message pruning
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Compaction only runs when needed
- No overhead when context is within limits
Usage:
from praisonaiagents.compaction import ContextCompactor, CompactionConfig
# Create a compactor
compactor = ContextCompactor(
max_tokens=8000,
strategy="summarize"
)
# Compact messages when needed
compacted = compactor.compact(messages)
"""
__all__ = [
# Core classes
"ContextCompactor",
"CompactionConfig",
# Strategies
"CompactionStrategy",
# Results
"CompactionResult",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "ContextCompactor":
from .compactor import ContextCompactor
return ContextCompactor
if name == "CompactionConfig":
from .config import CompactionConfig
return CompactionConfig
if name == "CompactionStrategy":
from .strategy import CompactionStrategy
return CompactionStrategy
if name == "CompactionResult":
from .result import CompactionResult
return CompactionResult
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,237 @@
"""
Context Compactor for PraisonAI Agents.
Manages context window by compacting messages when needed.
"""
from typing import List, Dict, Any, Optional
from .config import CompactionConfig
from .strategy import CompactionStrategy
from .result import CompactionResult
class ContextCompactor:
"""
Compacts conversation context to fit within token limits.
Example:
compactor = ContextCompactor(max_tokens=8000)
# Check if compaction needed
if compactor.needs_compaction(messages):
result = compactor.compact(messages)
messages = result.messages
"""
def __init__(
self,
max_tokens: int = 8000,
target_tokens: Optional[int] = None,
strategy: CompactionStrategy = CompactionStrategy.TRUNCATE,
preserve_system: bool = True,
preserve_recent: int = 5
):
"""
Initialize the compactor.
Args:
max_tokens: Maximum tokens before compaction
target_tokens: Target tokens after compaction
strategy: Compaction strategy to use
preserve_system: Keep system messages
preserve_recent: Number of recent messages to preserve
"""
self.max_tokens = max_tokens
self.target_tokens = target_tokens or int(max_tokens * 0.75)
self.strategy = strategy
self.preserve_system = preserve_system
self.preserve_recent = preserve_recent
def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text."""
# Rough estimate: ~4 chars per token
return len(text) // 4
def count_message_tokens(self, message: Dict[str, Any]) -> int:
"""Count tokens in a message."""
content = message.get("content", "")
if isinstance(content, str):
return self.estimate_tokens(content)
elif isinstance(content, list):
# Handle multi-part content
total = 0
for part in content:
if isinstance(part, dict):
total += self.estimate_tokens(str(part.get("text", "")))
else:
total += self.estimate_tokens(str(part))
return total
return 0
def count_total_tokens(self, messages: List[Dict[str, Any]]) -> int:
"""Count total tokens in messages."""
return sum(self.count_message_tokens(m) for m in messages)
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
"""Check if messages need compaction."""
return self.count_total_tokens(messages) > self.max_tokens
def compact(
self,
messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], CompactionResult]:
"""
Compact messages to fit within token limit.
Args:
messages: List of messages to compact
Returns:
Tuple of (compacted messages, result)
"""
original_tokens = self.count_total_tokens(messages)
if original_tokens <= self.max_tokens:
return messages, CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
messages_removed=0,
messages_kept=len(messages),
strategy_used=self.strategy
)
if self.strategy == CompactionStrategy.TRUNCATE:
compacted = self._truncate(messages)
elif self.strategy == CompactionStrategy.SLIDING:
compacted = self._sliding_window(messages)
elif self.strategy == CompactionStrategy.SUMMARIZE:
compacted = self._summarize(messages)
elif self.strategy == CompactionStrategy.SMART:
compacted = self._smart_compact(messages)
else:
compacted = self._truncate(messages)
compacted_tokens = self.count_total_tokens(compacted)
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=compacted_tokens,
messages_removed=len(messages) - len(compacted),
messages_kept=len(compacted),
strategy_used=self.strategy
)
return compacted, result
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Truncate oldest messages."""
result = []
# Separate system and non-system messages
system_msgs = []
other_msgs = []
for msg in messages:
if self.preserve_system and msg.get("role") == "system":
system_msgs.append(msg)
else:
other_msgs.append(msg)
# Always keep system messages
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
# Add recent messages
result.extend(recent)
# If still over limit, truncate more
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
# Remove oldest non-system message
for i, msg in enumerate(result):
if msg.get("role") != "system":
result.pop(i)
break
return result
def _sliding_window(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Keep a sliding window of recent messages."""
result = []
# Keep system messages
for msg in messages:
if self.preserve_system and msg.get("role") == "system":
result.append(msg)
# Add messages from end until we hit target
non_system = [m for m in messages if m.get("role") != "system"]
for msg in reversed(non_system):
if self.count_total_tokens(result + [msg]) <= self.target_tokens:
result.insert(len([m for m in result if m.get("role") == "system"]), msg)
else:
break
# Ensure messages are in order
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = [m for m in result if m.get("role") != "system"]
return system_msgs + other_msgs
def _summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Summarize old messages (simplified version)."""
result = []
# Keep system messages
system_msgs = [m for m in messages if m.get("role") == "system"]
other_msgs = [m for m in messages if m.get("role") != "system"]
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Summarize older messages
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
if older:
# Create a simple summary
summary_parts = []
for msg in older:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content:
# Take first 100 chars
summary_parts.append(f"{role}: {content[:100]}...")
if summary_parts:
summary = "[Previous conversation summary]\n" + "\n".join(summary_parts[:5])
result.append({
"role": "system",
"content": summary
})
result.extend(recent)
return result
def _smart_compact(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Smart compaction based on message importance."""
# For now, use sliding window as base
return self._sliding_window(messages)
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Get statistics about messages."""
total_tokens = self.count_total_tokens(messages)
return {
"message_count": len(messages),
"total_tokens": total_tokens,
"max_tokens": self.max_tokens,
"target_tokens": self.target_tokens,
"needs_compaction": total_tokens > self.max_tokens,
"utilization": total_tokens / self.max_tokens if self.max_tokens > 0 else 0
}
@@ -0,0 +1,17 @@
"""
Compaction Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class CompactionConfig:
"""Configuration for context compaction."""
enabled: bool = True
max_tokens: int = 8000
target_tokens: int = 6000 # Target after compaction
preserve_system: bool = True # Always keep system messages
preserve_recent: int = 5 # Keep last N messages
auto_compact: bool = True # Automatically compact when needed
@@ -0,0 +1,49 @@
"""
Compaction Result for PraisonAI Agents.
"""
from dataclasses import dataclass, field
from typing import List, Dict, Any
from .strategy import CompactionStrategy
@dataclass
class CompactionResult:
"""Result of a compaction operation."""
original_tokens: int
compacted_tokens: int
messages_removed: int
messages_kept: int
strategy_used: CompactionStrategy
summary: str = ""
@property
def tokens_saved(self) -> int:
"""Get number of tokens saved."""
return self.original_tokens - self.compacted_tokens
@property
def compression_ratio(self) -> float:
"""Get compression ratio."""
if self.original_tokens == 0:
return 0.0
return self.compacted_tokens / self.original_tokens
@property
def was_compacted(self) -> bool:
"""Check if compaction occurred."""
return self.messages_removed > 0 or self.tokens_saved > 0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"original_tokens": self.original_tokens,
"compacted_tokens": self.compacted_tokens,
"tokens_saved": self.tokens_saved,
"messages_removed": self.messages_removed,
"messages_kept": self.messages_kept,
"strategy_used": self.strategy_used.value,
"compression_ratio": self.compression_ratio,
"summary": self.summary
}
@@ -0,0 +1,13 @@
"""
Compaction Strategies for PraisonAI Agents.
"""
from enum import Enum
class CompactionStrategy(str, Enum):
"""Strategy for compacting context."""
TRUNCATE = "truncate" # Remove oldest messages
SUMMARIZE = "summarize" # Summarize old messages
SLIDING = "sliding" # Sliding window of recent messages
SMART = "smart" # Intelligent selection based on relevance
@@ -0,0 +1,68 @@
"""
PraisonAI Agents Evaluation Framework.
Provides comprehensive evaluation capabilities for AI agents with zero performance
impact when not in use through lazy loading.
Evaluator Types:
- AccuracyEvaluator: Compare output against expected output using LLM-as-judge
- PerformanceEvaluator: Measure runtime and memory usage
- ReliabilityEvaluator: Verify expected tool calls are made
- CriteriaEvaluator: Evaluate against custom criteria
Example:
>>> from praisonaiagents.eval import AccuracyEvaluator
>>> evaluator = AccuracyEvaluator(
... agent=my_agent,
... input_text="What is 2+2?",
... expected_output="4"
... )
>>> result = evaluator.run(print_summary=True)
"""
__all__ = [
"BaseEvaluator",
"AccuracyEvaluator",
"PerformanceEvaluator",
"ReliabilityEvaluator",
"CriteriaEvaluator",
"AccuracyResult",
"PerformanceResult",
"ReliabilityResult",
"CriteriaResult",
"EvaluationScore",
"PerformanceMetrics",
"ToolCallResult",
"CriteriaScore",
]
_LAZY_IMPORTS = {
"BaseEvaluator": ("base", "BaseEvaluator"),
"AccuracyEvaluator": ("accuracy", "AccuracyEvaluator"),
"PerformanceEvaluator": ("performance", "PerformanceEvaluator"),
"ReliabilityEvaluator": ("reliability", "ReliabilityEvaluator"),
"CriteriaEvaluator": ("criteria", "CriteriaEvaluator"),
"AccuracyResult": ("results", "AccuracyResult"),
"PerformanceResult": ("results", "PerformanceResult"),
"ReliabilityResult": ("results", "ReliabilityResult"),
"CriteriaResult": ("results", "CriteriaResult"),
"EvaluationScore": ("results", "EvaluationScore"),
"PerformanceMetrics": ("results", "PerformanceMetrics"),
"ToolCallResult": ("results", "ToolCallResult"),
"CriteriaScore": ("results", "CriteriaScore"),
}
def __getattr__(name: str):
"""Lazy import mechanism for zero-cost imports when not used."""
if name in _LAZY_IMPORTS:
module_name, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(f".{module_name}", __name__)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
"""Return list of available attributes for tab completion."""
return __all__
@@ -0,0 +1,239 @@
"""
Accuracy evaluator for PraisonAI Agents.
Evaluates agent output accuracy by comparing against expected output using LLM-as-judge.
"""
import os
import logging
from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING
from .base import BaseEvaluator
from .results import AccuracyResult, EvaluationScore
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..agents.agents import PraisonAIAgents
logger = logging.getLogger(__name__)
class AccuracyEvaluator(BaseEvaluator):
"""
Evaluates the accuracy of agent outputs against expected outputs.
Uses an LLM as a judge to score outputs on a scale of 1-10.
"""
def __init__(
self,
agent: Optional[Union["Agent", "PraisonAIAgents"]] = None,
func: Optional[Callable[..., str]] = None,
input_text: str = "",
expected_output: str = "",
num_iterations: int = 1,
model: Optional[str] = None,
name: Optional[str] = None,
save_results_path: Optional[str] = None,
verbose: bool = False,
):
"""
Initialize the accuracy evaluator.
Args:
agent: Agent or PraisonAIAgents instance to evaluate
func: Alternative callable that returns output string
input_text: Input to provide to the agent/function
expected_output: Expected output to compare against
num_iterations: Number of evaluation iterations
model: LLM model to use for judging (defaults to gpt-4o-mini)
name: Name for this evaluation
save_results_path: Path to save results
verbose: Enable verbose output
"""
super().__init__(name=name, save_results_path=save_results_path, verbose=verbose)
self.agent = agent
self.func = func
self.input_text = input_text
self.expected_output = expected_output
self.num_iterations = num_iterations
self.model = model or os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini")
if agent is None and func is None:
raise ValueError("Either 'agent' or 'func' must be provided")
def _get_output(self) -> str:
"""Get output from agent or function."""
if self.func:
return str(self.func(self.input_text))
if hasattr(self.agent, 'chat'):
return str(self.agent.chat(self.input_text))
elif hasattr(self.agent, 'start'):
result = self.agent.start(self.input_text)
if hasattr(result, 'raw'):
return str(result.raw)
return str(result)
else:
raise ValueError("Agent must have 'chat' or 'start' method")
def _judge_output(self, output: str) -> EvaluationScore:
"""
Use LLM to judge the output against expected output.
Args:
output: The actual output to evaluate
Returns:
EvaluationScore with score and reasoning
"""
try:
from openai import OpenAI
except ImportError:
raise ImportError("OpenAI package required for accuracy evaluation. Install with: pip install openai")
client = OpenAI()
prompt = f"""You are an expert evaluator. Compare the actual output against the expected output and provide a score from 1-10.
Input: {self.input_text}
Expected Output: {self.expected_output}
Actual Output: {output}
Scoring Guidelines:
- 10: Perfect match in meaning and completeness
- 8-9: Very close, minor differences that don't affect correctness
- 6-7: Mostly correct but missing some details or has minor errors
- 4-5: Partially correct but significant issues
- 2-3: Mostly incorrect but shows some understanding
- 1: Completely wrong or irrelevant
Respond in this exact format:
SCORE: [number 1-10]
REASONING: [brief explanation]"""
response = client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=200
)
response_text = response.choices[0].message.content or ""
score = 5.0
reasoning = "Unable to parse response"
lines = response_text.strip().split('\n')
for line in lines:
if line.startswith('SCORE:'):
try:
score = float(line.replace('SCORE:', '').strip())
score = max(1.0, min(10.0, score))
except ValueError:
pass
elif line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
return EvaluationScore(
score=score,
reasoning=reasoning,
input_text=self.input_text,
output_text=output,
expected_output=self.expected_output
)
def run(self, print_summary: bool = False) -> AccuracyResult:
"""
Execute the accuracy evaluation.
Args:
print_summary: Whether to print summary after evaluation
Returns:
AccuracyResult with all evaluation scores
"""
self.before_run()
result = AccuracyResult(
eval_id=self.eval_id,
name=self.name
)
for i in range(self.num_iterations):
if self.verbose:
logger.info(f"Running iteration {i + 1}/{self.num_iterations}")
try:
output = self._get_output()
score = self._judge_output(output)
result.evaluations.append(score)
if self.verbose:
logger.info(f" Score: {score.score}/10 - {score.reasoning}")
except Exception as e:
logger.error(f"Error in iteration {i + 1}: {e}")
result.evaluations.append(EvaluationScore(
score=0.0,
reasoning=f"Error: {str(e)}",
input_text=self.input_text,
output_text="",
expected_output=self.expected_output
))
self.after_run(result)
if print_summary:
result.print_summary()
return result
async def run_async(self, print_summary: bool = False) -> AccuracyResult:
"""
Execute the accuracy evaluation asynchronously.
Args:
print_summary: Whether to print summary after evaluation
Returns:
AccuracyResult with all evaluation scores
"""
await self.async_before_run()
result = self.run(print_summary=False)
await self.async_after_run(result)
if print_summary:
result.print_summary()
return result
def evaluate_output(self, output: str, print_summary: bool = False) -> AccuracyResult:
"""
Evaluate a pre-generated output without running the agent.
Args:
output: The output to evaluate
print_summary: Whether to print summary after evaluation
Returns:
AccuracyResult with evaluation score
"""
self.before_run()
result = AccuracyResult(
eval_id=self.eval_id,
name=self.name
)
score = self._judge_output(output)
result.evaluations.append(score)
self.after_run(result)
if print_summary:
result.print_summary()
return result
@@ -0,0 +1,143 @@
"""
Base evaluator class for PraisonAI Agents evaluation framework.
This module provides the abstract base class that all evaluators inherit from.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Union, TYPE_CHECKING
import uuid
import logging
if TYPE_CHECKING:
from .results import AccuracyResult, PerformanceResult, ReliabilityResult, CriteriaResult
logger = logging.getLogger(__name__)
class BaseEvaluator(ABC):
"""
Abstract base class for all evaluators in PraisonAI Agents.
Provides common functionality for evaluation lifecycle management,
result storage, and telemetry integration.
"""
def __init__(
self,
name: Optional[str] = None,
save_results_path: Optional[str] = None,
verbose: bool = False,
):
"""
Initialize the base evaluator.
Args:
name: Optional name for this evaluation run
save_results_path: Optional file path to save results (supports {name}, {eval_id} placeholders)
verbose: Enable verbose output during evaluation
"""
self.eval_id = str(uuid.uuid4())[:8]
self.name = name or f"eval_{self.eval_id}"
self.save_results_path = save_results_path
self.verbose = verbose
self._result = None
def before_run(self) -> None:
"""
Hook called before evaluation runs.
Override in subclasses for custom pre-evaluation logic.
"""
if self.verbose:
logger.info(f"Starting evaluation: {self.name} (ID: {self.eval_id})")
def after_run(self, result: Any) -> None:
"""
Hook called after evaluation completes.
Override in subclasses for custom post-evaluation logic.
Args:
result: The evaluation result
"""
if self.verbose:
logger.info(f"Completed evaluation: {self.name}")
if self.save_results_path:
self._save_result(result)
async def async_before_run(self) -> None:
"""
Async hook called before evaluation runs.
Override in subclasses for custom async pre-evaluation logic.
"""
self.before_run()
async def async_after_run(self, result: Any) -> None:
"""
Async hook called after evaluation completes.
Override in subclasses for custom async post-evaluation logic.
Args:
result: The evaluation result
"""
self.after_run(result)
@abstractmethod
def run(self, **kwargs) -> Any:
"""
Execute the evaluation synchronously.
Returns:
Evaluation result (type depends on evaluator)
"""
pass
async def run_async(self, **kwargs) -> Any:
"""
Execute the evaluation asynchronously.
Default implementation calls sync run().
Override for true async evaluation.
Returns:
Evaluation result (type depends on evaluator)
"""
return self.run(**kwargs)
def _save_result(self, result: Any) -> None:
"""
Save evaluation result to file.
Args:
result: The evaluation result to save
"""
if not self.save_results_path:
return
try:
import json
from pathlib import Path
file_path = self.save_results_path.format(
name=self.name,
eval_id=self.eval_id
)
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
if hasattr(result, 'to_dict'):
data = result.to_dict()
elif hasattr(result, '__dict__'):
data = result.__dict__
else:
data = {"result": str(result)}
path.write_text(json.dumps(data, indent=2, default=str))
if self.verbose:
logger.info(f"Saved results to: {file_path}")
except Exception as e:
logger.warning(f"Failed to save results: {e}")
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name={self.name!r}, eval_id={self.eval_id!r})"
@@ -0,0 +1,289 @@
"""
Criteria evaluator for PraisonAI Agents.
Evaluates agent outputs against custom criteria using LLM-as-judge.
"""
import os
import logging
from typing import Callable, List, Literal, Optional, Union, TYPE_CHECKING
from .base import BaseEvaluator
from .results import CriteriaResult, CriteriaScore
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..agents.agents import PraisonAIAgents
logger = logging.getLogger(__name__)
class CriteriaEvaluator(BaseEvaluator):
"""
Evaluates agent outputs against custom criteria.
Uses an LLM as a judge to score outputs based on user-defined criteria.
Supports both numeric (1-10) and binary (pass/fail) scoring.
"""
def __init__(
self,
criteria: str,
agent: Optional[Union["Agent", "PraisonAIAgents"]] = None,
func: Optional[Callable[..., str]] = None,
input_text: str = "",
scoring_type: Literal["numeric", "binary"] = "numeric",
threshold: float = 7.0,
num_iterations: int = 1,
model: Optional[str] = None,
on_fail: Optional[Callable[[CriteriaScore], None]] = None,
name: Optional[str] = None,
save_results_path: Optional[str] = None,
verbose: bool = False,
):
"""
Initialize the criteria evaluator.
Args:
criteria: The criteria to evaluate against (e.g., "Response is helpful and accurate")
agent: Agent or PraisonAIAgents instance to evaluate
func: Alternative callable that returns output string
input_text: Input to provide to the agent/function
scoring_type: "numeric" (1-10 scale) or "binary" (pass/fail)
threshold: Score threshold for passing (numeric mode only)
num_iterations: Number of evaluation iterations
model: LLM model to use for judging (defaults to gpt-4o-mini)
on_fail: Callback function called when evaluation fails
name: Name for this evaluation
save_results_path: Path to save results
verbose: Enable verbose output
"""
super().__init__(name=name, save_results_path=save_results_path, verbose=verbose)
self.criteria = criteria
self.agent = agent
self.func = func
self.input_text = input_text
self.scoring_type = scoring_type
self.threshold = threshold
self.num_iterations = num_iterations
self.model = model or os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini")
self.on_fail = on_fail
def _get_output(self) -> str:
"""Get output from agent or function."""
if self.func:
return str(self.func(self.input_text))
if self.agent is None:
raise ValueError("Either 'agent' or 'func' must be provided")
if hasattr(self.agent, 'chat'):
return str(self.agent.chat(self.input_text))
elif hasattr(self.agent, 'start'):
result = self.agent.start(self.input_text)
if hasattr(result, 'raw'):
return str(result.raw)
return str(result)
else:
raise ValueError("Agent must have 'chat' or 'start' method")
def _judge_output(self, output: str) -> CriteriaScore:
"""
Use LLM to judge the output against criteria.
Args:
output: The output to evaluate
Returns:
CriteriaScore with score, passed status, and reasoning
"""
try:
from openai import OpenAI
except ImportError:
raise ImportError("OpenAI package required for criteria evaluation. Install with: pip install openai")
client = OpenAI()
if self.scoring_type == "binary":
prompt = f"""You are an expert evaluator. Evaluate the following output against the given criteria.
Criteria: {self.criteria}
Output to evaluate:
{output}
Determine if the output PASSES or FAILS the criteria.
Respond in this exact format:
RESULT: [PASS or FAIL]
REASONING: [brief explanation]"""
else:
prompt = f"""You are an expert evaluator. Evaluate the following output against the given criteria.
Criteria: {self.criteria}
Output to evaluate:
{output}
Score the output from 1-10 based on how well it meets the criteria.
- 10: Perfectly meets all criteria
- 8-9: Meets criteria very well with minor issues
- 6-7: Meets most criteria but has some gaps
- 4-5: Partially meets criteria
- 2-3: Barely meets criteria
- 1: Does not meet criteria at all
Respond in this exact format:
SCORE: [number 1-10]
REASONING: [brief explanation]"""
response = client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=200
)
response_text = response.choices[0].message.content or ""
score = 5.0
passed = False
reasoning = "Unable to parse response"
lines = response_text.strip().split('\n')
for line in lines:
if self.scoring_type == "binary":
if line.startswith('RESULT:'):
result_str = line.replace('RESULT:', '').strip().upper()
passed = result_str == 'PASS'
score = 10.0 if passed else 0.0
else:
if line.startswith('SCORE:'):
try:
score = float(line.replace('SCORE:', '').strip())
score = max(1.0, min(10.0, score))
passed = score >= self.threshold
except ValueError:
pass
if line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
return CriteriaScore(
score=score,
passed=passed,
reasoning=reasoning,
output_text=output,
criteria=self.criteria
)
def run(self, print_summary: bool = False) -> CriteriaResult:
"""
Execute the criteria evaluation.
Args:
print_summary: Whether to print summary after evaluation
Returns:
CriteriaResult with all evaluation scores
"""
self.before_run()
result = CriteriaResult(
criteria=self.criteria,
scoring_type=self.scoring_type,
threshold=self.threshold,
eval_id=self.eval_id,
name=self.name
)
for i in range(self.num_iterations):
if self.verbose:
logger.info(f"Running iteration {i + 1}/{self.num_iterations}")
try:
output = self._get_output()
score = self._judge_output(output)
result.evaluations.append(score)
if self.verbose:
status = "PASS" if score.passed else "FAIL"
logger.info(f" {status} (Score: {score.score}) - {score.reasoning}")
if not score.passed and self.on_fail:
self.on_fail(score)
except Exception as e:
logger.error(f"Error in iteration {i + 1}: {e}")
error_score = CriteriaScore(
score=0.0,
passed=False,
reasoning=f"Error: {str(e)}",
output_text="",
criteria=self.criteria
)
result.evaluations.append(error_score)
if self.on_fail:
self.on_fail(error_score)
self.after_run(result)
if print_summary:
result.print_summary()
return result
async def run_async(self, print_summary: bool = False) -> CriteriaResult:
"""
Execute the criteria evaluation asynchronously.
Args:
print_summary: Whether to print summary after evaluation
Returns:
CriteriaResult with all evaluation scores
"""
await self.async_before_run()
result = self.run(print_summary=False)
await self.async_after_run(result)
if print_summary:
result.print_summary()
return result
def evaluate_output(self, output: str, print_summary: bool = False) -> CriteriaResult:
"""
Evaluate a pre-generated output without running the agent.
Args:
output: The output to evaluate
print_summary: Whether to print summary after evaluation
Returns:
CriteriaResult with evaluation score
"""
self.before_run()
result = CriteriaResult(
criteria=self.criteria,
scoring_type=self.scoring_type,
threshold=self.threshold,
eval_id=self.eval_id,
name=self.name
)
score = self._judge_output(output)
result.evaluations.append(score)
if not score.passed and self.on_fail:
self.on_fail(score)
self.after_run(result)
if print_summary:
result.print_summary()
return result
@@ -0,0 +1,184 @@
"""
Performance evaluator for PraisonAI Agents.
Measures runtime and memory usage of agent executions or functions.
"""
import time
import logging
from typing import Callable, Optional, TYPE_CHECKING
from .base import BaseEvaluator
from .results import PerformanceResult, PerformanceMetrics
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..agents.agents import PraisonAIAgents
logger = logging.getLogger(__name__)
class PerformanceEvaluator(BaseEvaluator):
"""
Evaluates the performance of agent executions or functions.
Measures runtime and memory usage across multiple iterations.
"""
def __init__(
self,
func: Optional[Callable[[], None]] = None,
agent: Optional["Agent"] = None,
input_text: str = "",
num_iterations: int = 10,
warmup_runs: int = 2,
track_memory: bool = True,
name: Optional[str] = None,
save_results_path: Optional[str] = None,
verbose: bool = False,
):
"""
Initialize the performance evaluator.
Args:
func: Callable to benchmark (takes no arguments)
agent: Agent to benchmark (will call chat with input_text)
input_text: Input text for agent benchmarking
num_iterations: Number of benchmark iterations
warmup_runs: Number of warmup runs before measurement
track_memory: Whether to track memory usage
name: Name for this evaluation
save_results_path: Path to save results
verbose: Enable verbose output
"""
super().__init__(name=name, save_results_path=save_results_path, verbose=verbose)
self.func = func
self.agent = agent
self.input_text = input_text
self.num_iterations = num_iterations
self.warmup_runs = warmup_runs
self.track_memory = track_memory
if func is None and agent is None:
raise ValueError("Either 'func' or 'agent' must be provided")
def _get_memory_usage(self) -> float:
"""Get current memory usage in MB."""
if not self.track_memory:
return 0.0
try:
import tracemalloc
current, _ = tracemalloc.get_traced_memory()
return current / (1024 * 1024)
except Exception:
return 0.0
def _execute(self) -> None:
"""Execute the function or agent."""
if self.func:
self.func()
elif self.agent:
if hasattr(self.agent, 'chat'):
self.agent.chat(self.input_text)
elif hasattr(self.agent, 'start'):
self.agent.start(self.input_text)
else:
raise ValueError("Agent must have 'chat' or 'start' method")
def run(self, print_summary: bool = False) -> PerformanceResult:
"""
Execute the performance evaluation.
Args:
print_summary: Whether to print summary after evaluation
Returns:
PerformanceResult with all performance metrics
"""
self.before_run()
result = PerformanceResult(
warmup_runs=self.warmup_runs,
eval_id=self.eval_id,
name=self.name
)
if self.track_memory:
try:
import tracemalloc
tracemalloc.start()
except Exception as e:
logger.warning(f"Could not start memory tracking: {e}")
self.track_memory = False
if self.verbose:
logger.info(f"Running {self.warmup_runs} warmup iterations...")
for i in range(self.warmup_runs):
try:
self._execute()
except Exception as e:
logger.warning(f"Warmup iteration {i + 1} failed: {e}")
if self.verbose:
logger.info(f"Running {self.num_iterations} benchmark iterations...")
for i in range(self.num_iterations):
memory_before = self._get_memory_usage()
start_time = time.perf_counter()
try:
self._execute()
success = True
except Exception as e:
logger.error(f"Iteration {i + 1} failed: {e}")
success = False
end_time = time.perf_counter()
memory_after = self._get_memory_usage()
if success:
metrics = PerformanceMetrics(
run_time=end_time - start_time,
memory_usage=max(0, memory_after - memory_before),
iteration=i + 1
)
result.metrics.append(metrics)
if self.verbose:
logger.info(f" Iteration {i + 1}: {metrics.run_time:.4f}s, {metrics.memory_usage:.2f}MB")
if self.track_memory:
try:
import tracemalloc
tracemalloc.stop()
except Exception:
pass
self.after_run(result)
if print_summary:
result.print_summary()
return result
async def run_async(self, print_summary: bool = False) -> PerformanceResult:
"""
Execute the performance evaluation asynchronously.
Args:
print_summary: Whether to print summary after evaluation
Returns:
PerformanceResult with all performance metrics
"""
await self.async_before_run()
result = self.run(print_summary=False)
await self.async_after_run(result)
if print_summary:
result.print_summary()
return result
@@ -0,0 +1,225 @@
"""
Reliability evaluator for PraisonAI Agents.
Evaluates agent reliability by verifying expected tool calls are made.
"""
import logging
from typing import Dict, List, Optional, Set, Any, TYPE_CHECKING
from .base import BaseEvaluator
from .results import ReliabilityResult, ToolCallResult
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..agents.agents import PraisonAIAgents
logger = logging.getLogger(__name__)
class ReliabilityEvaluator(BaseEvaluator):
"""
Evaluates the reliability of agent tool usage.
Verifies that expected tools are called during agent execution.
"""
def __init__(
self,
agent: Optional["Agent"] = None,
input_text: str = "",
expected_tools: Optional[List[str]] = None,
forbidden_tools: Optional[List[str]] = None,
name: Optional[str] = None,
save_results_path: Optional[str] = None,
verbose: bool = False,
):
"""
Initialize the reliability evaluator.
Args:
agent: Agent to evaluate
input_text: Input to provide to the agent
expected_tools: List of tool names that should be called
forbidden_tools: List of tool names that should NOT be called
name: Name for this evaluation
save_results_path: Path to save results
verbose: Enable verbose output
"""
super().__init__(name=name, save_results_path=save_results_path, verbose=verbose)
self.agent = agent
self.input_text = input_text
self.expected_tools = set(expected_tools or [])
self.forbidden_tools = set(forbidden_tools or [])
if agent is None:
raise ValueError("'agent' must be provided")
def _extract_tool_calls(self, response: Any) -> Set[str]:
"""
Extract tool names from agent response.
Args:
response: Agent response object
Returns:
Set of tool names that were called
"""
tool_calls = set()
if hasattr(response, 'tool_calls') and response.tool_calls:
for tc in response.tool_calls:
if hasattr(tc, 'name'):
tool_calls.add(tc.name)
elif hasattr(tc, 'function') and hasattr(tc.function, 'name'):
tool_calls.add(tc.function.name)
if hasattr(self.agent, 'chat_history'):
for msg in self.agent.chat_history:
if isinstance(msg, dict):
if msg.get('role') == 'assistant' and 'tool_calls' in msg:
for tc in msg.get('tool_calls', []):
if isinstance(tc, dict) and 'function' in tc:
tool_calls.add(tc['function'].get('name', ''))
elif msg.get('role') == 'tool':
tool_name = msg.get('name', '')
if tool_name:
tool_calls.add(tool_name)
return tool_calls
def run(self, print_summary: bool = False) -> ReliabilityResult:
"""
Execute the reliability evaluation.
Args:
print_summary: Whether to print summary after evaluation
Returns:
ReliabilityResult with tool call verification results
"""
self.before_run()
result = ReliabilityResult(
eval_id=self.eval_id,
name=self.name
)
try:
if hasattr(self.agent, 'chat'):
response = self.agent.chat(self.input_text)
elif hasattr(self.agent, 'start'):
response = self.agent.start(self.input_text)
else:
raise ValueError("Agent must have 'chat' or 'start' method")
actual_tools = self._extract_tool_calls(response)
if self.verbose:
logger.info(f"Expected tools: {self.expected_tools}")
logger.info(f"Actual tools called: {actual_tools}")
for tool in self.expected_tools:
was_called = tool in actual_tools
result.tool_results.append(ToolCallResult(
tool_name=tool,
expected=True,
actual=was_called,
passed=was_called
))
for tool in self.forbidden_tools:
was_called = tool in actual_tools
result.tool_results.append(ToolCallResult(
tool_name=tool,
expected=False,
actual=was_called,
passed=not was_called
))
except Exception as e:
logger.error(f"Error during reliability evaluation: {e}")
for tool in self.expected_tools:
result.tool_results.append(ToolCallResult(
tool_name=tool,
expected=True,
actual=False,
passed=False,
metadata={"error": str(e)}
))
self.after_run(result)
if print_summary:
result.print_summary()
return result
async def run_async(self, print_summary: bool = False) -> ReliabilityResult:
"""
Execute the reliability evaluation asynchronously.
Args:
print_summary: Whether to print summary after evaluation
Returns:
ReliabilityResult with tool call verification results
"""
await self.async_before_run()
result = self.run(print_summary=False)
await self.async_after_run(result)
if print_summary:
result.print_summary()
return result
def evaluate_tool_calls(
self,
actual_tools: List[str],
print_summary: bool = False
) -> ReliabilityResult:
"""
Evaluate a pre-recorded list of tool calls without running the agent.
Args:
actual_tools: List of tool names that were called
print_summary: Whether to print summary after evaluation
Returns:
ReliabilityResult with tool call verification results
"""
self.before_run()
result = ReliabilityResult(
eval_id=self.eval_id,
name=self.name
)
actual_set = set(actual_tools)
for tool in self.expected_tools:
was_called = tool in actual_set
result.tool_results.append(ToolCallResult(
tool_name=tool,
expected=True,
actual=was_called,
passed=was_called
))
for tool in self.forbidden_tools:
was_called = tool in actual_set
result.tool_results.append(ToolCallResult(
tool_name=tool,
expected=False,
actual=was_called,
passed=not was_called
))
self.after_run(result)
if print_summary:
result.print_summary()
return result
@@ -0,0 +1,463 @@
"""
Result dataclasses for PraisonAI Agents evaluation framework.
This module provides structured result types for different evaluation methods.
"""
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict, Any
from datetime import datetime
import statistics
import json
@dataclass
class EvaluationScore:
"""Individual evaluation score with reasoning."""
score: float
reasoning: str
input_text: str
output_text: str
expected_output: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class AccuracyResult:
"""Result from accuracy evaluation comparing output to expected output."""
evaluations: List[EvaluationScore] = field(default_factory=list)
eval_id: str = ""
name: str = ""
@property
def scores(self) -> List[float]:
"""Get list of all scores."""
return [e.score for e in self.evaluations]
@property
def avg_score(self) -> float:
"""Calculate average score."""
if not self.scores:
return 0.0
return statistics.mean(self.scores)
@property
def min_score(self) -> float:
"""Get minimum score."""
if not self.scores:
return 0.0
return min(self.scores)
@property
def max_score(self) -> float:
"""Get maximum score."""
if not self.scores:
return 0.0
return max(self.scores)
@property
def std_dev(self) -> float:
"""Calculate standard deviation of scores."""
if len(self.scores) < 2:
return 0.0
return statistics.stdev(self.scores)
@property
def passed(self) -> bool:
"""Check if evaluation passed (avg score >= 7)."""
return self.avg_score >= 7.0
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"eval_id": self.eval_id,
"name": self.name,
"evaluations": [asdict(e) for e in self.evaluations],
"avg_score": self.avg_score,
"min_score": self.min_score,
"max_score": self.max_score,
"std_dev": self.std_dev,
"passed": self.passed,
"num_evaluations": len(self.evaluations)
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
def print_summary(self) -> None:
"""Print a summary of the evaluation results."""
try:
from rich.console import Console
from rich.table import Table
from rich.panel import Panel # noqa: F401
console = Console()
table = Table(title="Accuracy Evaluation Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table.add_row("Evaluations", str(len(self.evaluations)))
table.add_row("Average Score", f"{self.avg_score:.2f}")
table.add_row("Min Score", f"{self.min_score:.2f}")
table.add_row("Max Score", f"{self.max_score:.2f}")
table.add_row("Std Dev", f"{self.std_dev:.2f}")
table.add_row("Status", "✅ PASSED" if self.passed else "❌ FAILED")
console.print(table)
except ImportError:
print("Accuracy Evaluation Summary")
print(f" Evaluations: {len(self.evaluations)}")
print(f" Average Score: {self.avg_score:.2f}")
print(f" Min Score: {self.min_score:.2f}")
print(f" Max Score: {self.max_score:.2f}")
print(f" Std Dev: {self.std_dev:.2f}")
print(f" Status: {'PASSED' if self.passed else 'FAILED'}")
@dataclass
class PerformanceMetrics:
"""Individual performance measurement."""
run_time: float
memory_usage: float
iteration: int
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class PerformanceResult:
"""Result from performance evaluation measuring runtime and memory."""
metrics: List[PerformanceMetrics] = field(default_factory=list)
warmup_runs: int = 0
eval_id: str = ""
name: str = ""
@property
def run_times(self) -> List[float]:
"""Get list of all run times."""
return [m.run_time for m in self.metrics]
@property
def memory_usages(self) -> List[float]:
"""Get list of all memory usages."""
return [m.memory_usage for m in self.metrics]
@property
def avg_run_time(self) -> float:
"""Calculate average run time."""
if not self.run_times:
return 0.0
return statistics.mean(self.run_times)
@property
def min_run_time(self) -> float:
"""Get minimum run time."""
if not self.run_times:
return 0.0
return min(self.run_times)
@property
def max_run_time(self) -> float:
"""Get maximum run time."""
if not self.run_times:
return 0.0
return max(self.run_times)
@property
def std_dev_run_time(self) -> float:
"""Calculate standard deviation of run times."""
if len(self.run_times) < 2:
return 0.0
return statistics.stdev(self.run_times)
@property
def median_run_time(self) -> float:
"""Calculate median run time."""
if not self.run_times:
return 0.0
return statistics.median(self.run_times)
@property
def p95_run_time(self) -> float:
"""Calculate 95th percentile run time."""
if not self.run_times:
return 0.0
sorted_times = sorted(self.run_times)
idx = int(len(sorted_times) * 0.95)
return sorted_times[min(idx, len(sorted_times) - 1)]
@property
def avg_memory(self) -> float:
"""Calculate average memory usage."""
if not self.memory_usages:
return 0.0
return statistics.mean(self.memory_usages)
@property
def max_memory(self) -> float:
"""Get maximum memory usage."""
if not self.memory_usages:
return 0.0
return max(self.memory_usages)
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"eval_id": self.eval_id,
"name": self.name,
"metrics": [asdict(m) for m in self.metrics],
"warmup_runs": self.warmup_runs,
"avg_run_time": self.avg_run_time,
"min_run_time": self.min_run_time,
"max_run_time": self.max_run_time,
"std_dev_run_time": self.std_dev_run_time,
"median_run_time": self.median_run_time,
"p95_run_time": self.p95_run_time,
"avg_memory": self.avg_memory,
"max_memory": self.max_memory,
"num_iterations": len(self.metrics)
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
def print_summary(self) -> None:
"""Print a summary of the performance results."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Performance Evaluation Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table.add_row("Iterations", str(len(self.metrics)))
table.add_row("Warmup Runs", str(self.warmup_runs))
table.add_row("Avg Run Time", f"{self.avg_run_time:.4f}s")
table.add_row("Min Run Time", f"{self.min_run_time:.4f}s")
table.add_row("Max Run Time", f"{self.max_run_time:.4f}s")
table.add_row("Std Dev", f"{self.std_dev_run_time:.4f}s")
table.add_row("Median", f"{self.median_run_time:.4f}s")
table.add_row("P95", f"{self.p95_run_time:.4f}s")
table.add_row("Avg Memory", f"{self.avg_memory:.2f} MB")
table.add_row("Max Memory", f"{self.max_memory:.2f} MB")
console.print(table)
except ImportError:
print("Performance Evaluation Summary")
print(f" Iterations: {len(self.metrics)}")
print(f" Warmup Runs: {self.warmup_runs}")
print(f" Avg Run Time: {self.avg_run_time:.4f}s")
print(f" Min Run Time: {self.min_run_time:.4f}s")
print(f" Max Run Time: {self.max_run_time:.4f}s")
print(f" Std Dev: {self.std_dev_run_time:.4f}s")
print(f" Median: {self.median_run_time:.4f}s")
print(f" P95: {self.p95_run_time:.4f}s")
print(f" Avg Memory: {self.avg_memory:.2f} MB")
print(f" Max Memory: {self.max_memory:.2f} MB")
@dataclass
class ToolCallResult:
"""Result of a single tool call verification."""
tool_name: str
expected: bool
actual: bool
passed: bool
arguments: Optional[Dict[str, Any]] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ReliabilityResult:
"""Result from reliability evaluation checking tool calls."""
tool_results: List[ToolCallResult] = field(default_factory=list)
eval_id: str = ""
name: str = ""
@property
def passed_calls(self) -> List[ToolCallResult]:
"""Get list of passed tool calls."""
return [t for t in self.tool_results if t.passed]
@property
def failed_calls(self) -> List[ToolCallResult]:
"""Get list of failed tool calls."""
return [t for t in self.tool_results if not t.passed]
@property
def pass_rate(self) -> float:
"""Calculate pass rate."""
if not self.tool_results:
return 0.0
return len(self.passed_calls) / len(self.tool_results)
@property
def status(self) -> str:
"""Get overall status."""
return "PASSED" if len(self.failed_calls) == 0 else "FAILED"
@property
def passed(self) -> bool:
"""Check if all tool calls passed."""
return len(self.failed_calls) == 0
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"eval_id": self.eval_id,
"name": self.name,
"tool_results": [asdict(t) for t in self.tool_results],
"passed_count": len(self.passed_calls),
"failed_count": len(self.failed_calls),
"pass_rate": self.pass_rate,
"status": self.status
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
def print_summary(self) -> None:
"""Print a summary of the reliability results."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Reliability Evaluation Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table.add_row("Total Tool Calls", str(len(self.tool_results)))
table.add_row("Passed", str(len(self.passed_calls)))
table.add_row("Failed", str(len(self.failed_calls)))
table.add_row("Pass Rate", f"{self.pass_rate:.1%}")
table.add_row("Status", "✅ PASSED" if self.passed else "❌ FAILED")
console.print(table)
if self.failed_calls:
fail_table = Table(title="Failed Tool Calls")
fail_table.add_column("Tool", style="red")
fail_table.add_column("Expected", style="yellow")
fail_table.add_column("Actual", style="yellow")
for tc in self.failed_calls:
fail_table.add_row(tc.tool_name, str(tc.expected), str(tc.actual))
console.print(fail_table)
except ImportError:
print("Reliability Evaluation Summary")
print(f" Total Tool Calls: {len(self.tool_results)}")
print(f" Passed: {len(self.passed_calls)}")
print(f" Failed: {len(self.failed_calls)}")
print(f" Pass Rate: {self.pass_rate:.1%}")
print(f" Status: {self.status}")
@dataclass
class CriteriaScore:
"""Individual criteria evaluation score."""
score: float
passed: bool
reasoning: str
output_text: str
criteria: str
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class CriteriaResult:
"""Result from criteria-based evaluation (agent-as-judge)."""
evaluations: List[CriteriaScore] = field(default_factory=list)
criteria: str = ""
scoring_type: str = "numeric" # "numeric" or "binary"
threshold: float = 7.0
eval_id: str = ""
name: str = ""
@property
def scores(self) -> List[float]:
"""Get list of all scores."""
return [e.score for e in self.evaluations]
@property
def avg_score(self) -> float:
"""Calculate average score."""
if not self.scores:
return 0.0
return statistics.mean(self.scores)
@property
def pass_rate(self) -> float:
"""Calculate pass rate."""
if not self.evaluations:
return 0.0
passed = sum(1 for e in self.evaluations if e.passed)
return passed / len(self.evaluations)
@property
def passed(self) -> bool:
"""Check if evaluation passed overall."""
if self.scoring_type == "binary":
return self.pass_rate >= 0.5
return self.avg_score >= self.threshold
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"eval_id": self.eval_id,
"name": self.name,
"criteria": self.criteria,
"scoring_type": self.scoring_type,
"threshold": self.threshold,
"evaluations": [asdict(e) for e in self.evaluations],
"avg_score": self.avg_score,
"pass_rate": self.pass_rate,
"passed": self.passed,
"num_evaluations": len(self.evaluations)
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
def print_summary(self) -> None:
"""Print a summary of the criteria evaluation results."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Criteria Evaluation Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table.add_row("Criteria", self.criteria[:50] + "..." if len(self.criteria) > 50 else self.criteria)
table.add_row("Scoring Type", self.scoring_type)
table.add_row("Threshold", str(self.threshold))
table.add_row("Evaluations", str(len(self.evaluations)))
table.add_row("Average Score", f"{self.avg_score:.2f}")
table.add_row("Pass Rate", f"{self.pass_rate:.1%}")
table.add_row("Status", "✅ PASSED" if self.passed else "❌ FAILED")
console.print(table)
except ImportError:
print("Criteria Evaluation Summary")
print(f" Criteria: {self.criteria[:50]}...")
print(f" Scoring Type: {self.scoring_type}")
print(f" Threshold: {self.threshold}")
print(f" Evaluations: {len(self.evaluations)}")
print(f" Average Score: {self.avg_score:.2f}")
print(f" Pass Rate: {self.pass_rate:.1%}")
print(f" Status: {'PASSED' if self.passed else 'FAILED'}")
@@ -0,0 +1,154 @@
"""
Utility functions for PraisonAI Agents evaluation framework.
"""
import json
import logging
from pathlib import Path
from typing import Any, Dict, Optional, Union
logger = logging.getLogger(__name__)
def save_result_to_file(
result: Any,
file_path: str,
eval_id: Optional[str] = None,
name: Optional[str] = None
) -> bool:
"""
Save evaluation result to a file.
Args:
result: The evaluation result to save
file_path: Path to save the result (supports {name}, {eval_id} placeholders)
eval_id: Optional eval ID for placeholder substitution
name: Optional name for placeholder substitution
Returns:
True if save was successful, False otherwise
"""
try:
formatted_path = file_path.format(name=name or "", eval_id=eval_id or "")
path = Path(formatted_path)
path.parent.mkdir(parents=True, exist_ok=True)
if hasattr(result, 'to_dict'):
data = result.to_dict()
elif hasattr(result, '__dict__'):
data = result.__dict__
else:
data = {"result": str(result)}
path.write_text(json.dumps(data, indent=2, default=str))
logger.debug(f"Saved result to: {formatted_path}")
return True
except Exception as e:
logger.warning(f"Failed to save result: {e}")
return False
def load_result_from_file(file_path: str) -> Optional[Dict[str, Any]]:
"""
Load evaluation result from a file.
Args:
file_path: Path to the result file
Returns:
Dictionary containing the result data, or None if load failed
"""
try:
path = Path(file_path)
if not path.exists():
logger.warning(f"Result file not found: {file_path}")
return None
data = json.loads(path.read_text())
return data
except Exception as e:
logger.warning(f"Failed to load result: {e}")
return None
def format_score(score: float, max_score: float = 10.0) -> str:
"""
Format a score for display.
Args:
score: The score value
max_score: Maximum possible score
Returns:
Formatted score string
"""
return f"{score:.1f}/{max_score:.0f}"
def format_percentage(value: float) -> str:
"""
Format a value as a percentage.
Args:
value: Value between 0 and 1
Returns:
Formatted percentage string
"""
return f"{value * 100:.1f}%"
def format_duration(seconds: float) -> str:
"""
Format a duration in seconds for display.
Args:
seconds: Duration in seconds
Returns:
Formatted duration string
"""
if seconds < 0.001:
return f"{seconds * 1000000:.0f}μs"
elif seconds < 1:
return f"{seconds * 1000:.2f}ms"
elif seconds < 60:
return f"{seconds:.2f}s"
else:
minutes = int(seconds // 60)
secs = seconds % 60
return f"{minutes}m {secs:.1f}s"
def format_memory(mb: float) -> str:
"""
Format memory usage for display.
Args:
mb: Memory in megabytes
Returns:
Formatted memory string
"""
if mb < 1:
return f"{mb * 1024:.1f} KB"
elif mb < 1024:
return f"{mb:.2f} MB"
else:
return f"{mb / 1024:.2f} GB"
def calculate_pass_rate(passed: int, total: int) -> float:
"""
Calculate pass rate.
Args:
passed: Number of passed evaluations
total: Total number of evaluations
Returns:
Pass rate as a float between 0 and 1
"""
if total == 0:
return 0.0
return passed / total
@@ -0,0 +1,97 @@
"""
Hooks Module for PraisonAI Agents.
Provides a powerful hook system for intercepting and modifying agent behavior
at various lifecycle points. Unlike callbacks (which are for UI events),
hooks can intercept, modify, or block tool execution.
Features:
- Event-based hook system (BeforeTool, AfterTool, BeforeAgent, etc.)
- Shell command hooks for external integrations
- Python function hooks for in-process customization
- Matcher patterns for selective hook execution
- Sequential and parallel hook execution
- Decision outcomes (allow, deny, block, ask)
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Hooks only execute when registered
- No overhead when hooks are disabled
Usage:
from praisonaiagents.hooks import HookRegistry, HookEvent
# Register a Python function hook
registry = HookRegistry()
@registry.on(HookEvent.BEFORE_TOOL)
def my_hook(event_data):
if event_data.tool_name == "dangerous_tool":
return HookResult(decision="deny", reason="Tool blocked by policy")
return HookResult(decision="allow")
# Register a shell command hook
registry.register_command_hook(
event=HookEvent.BEFORE_TOOL,
command="python /path/to/validator.py",
matcher="write_*" # Only match tools starting with write_
)
# Use with Agent
agent = Agent(
name="MyAgent",
hooks=registry
)
"""
__all__ = [
# Core types
"HookEvent",
"HookDecision",
"HookResult",
"HookInput",
"HookOutput",
# Hook definitions
"HookDefinition",
"CommandHook",
"FunctionHook",
# Registry and runner
"HookRegistry",
"HookRunner",
# Event-specific inputs
"BeforeToolInput",
"AfterToolInput",
"BeforeAgentInput",
"AfterAgentInput",
"SessionStartInput",
"SessionEndInput",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("HookEvent", "HookDecision", "HookResult", "HookInput", "HookOutput"):
from .types import HookEvent, HookDecision, HookResult, HookInput, HookOutput
return locals()[name]
if name in ("HookDefinition", "CommandHook", "FunctionHook"):
from .types import HookDefinition, CommandHook, FunctionHook
return locals()[name]
if name == "HookRegistry":
from .registry import HookRegistry
return HookRegistry
if name == "HookRunner":
from .runner import HookRunner
return HookRunner
if name in ("BeforeToolInput", "AfterToolInput", "BeforeAgentInput",
"AfterAgentInput", "SessionStartInput", "SessionEndInput"):
from .events import (
BeforeToolInput, AfterToolInput, BeforeAgentInput,
AfterAgentInput, SessionStartInput, SessionEndInput
)
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,195 @@
"""
Event-specific input types for the hook system.
Each event type has its own input class with relevant fields.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from .types import HookInput
@dataclass
class BeforeToolInput(HookInput):
"""Input for BeforeTool hooks."""
tool_name: str = ""
tool_input: Dict[str, Any] = field(default_factory=dict)
tool_description: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"tool_name": self.tool_name,
"tool_input": self.tool_input,
"tool_description": self.tool_description
})
return base
@dataclass
class AfterToolInput(HookInput):
"""Input for AfterTool hooks."""
tool_name: str = ""
tool_input: Dict[str, Any] = field(default_factory=dict)
tool_output: Any = None
tool_error: Optional[str] = None
execution_time_ms: float = 0.0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"tool_name": self.tool_name,
"tool_input": self.tool_input,
"tool_output": str(self.tool_output) if self.tool_output else None,
"tool_error": self.tool_error,
"execution_time_ms": self.execution_time_ms
})
return base
@dataclass
class BeforeAgentInput(HookInput):
"""Input for BeforeAgent hooks."""
prompt: str = ""
conversation_history: List[Dict[str, Any]] = field(default_factory=list)
tools_available: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"prompt": self.prompt,
"conversation_history_length": len(self.conversation_history),
"tools_available": self.tools_available
})
return base
@dataclass
class AfterAgentInput(HookInput):
"""Input for AfterAgent hooks."""
prompt: str = ""
response: str = ""
tools_used: List[str] = field(default_factory=list)
total_tokens: int = 0
execution_time_ms: float = 0.0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"prompt": self.prompt,
"response": self.response[:500] if self.response else "", # Truncate for JSON
"tools_used": self.tools_used,
"total_tokens": self.total_tokens,
"execution_time_ms": self.execution_time_ms
})
return base
@dataclass
class SessionStartInput(HookInput):
"""Input for SessionStart hooks."""
source: str = "startup" # startup, resume, clear
session_name: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"source": self.source,
"session_name": self.session_name
})
return base
@dataclass
class SessionEndInput(HookInput):
"""Input for SessionEnd hooks."""
reason: str = "exit" # exit, clear, logout, error
total_turns: int = 0
total_tokens: int = 0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"reason": self.reason,
"total_turns": self.total_turns,
"total_tokens": self.total_tokens
})
return base
@dataclass
class BeforeLLMInput(HookInput):
"""Input for BeforeLLM hooks."""
messages: List[Dict[str, Any]] = field(default_factory=list)
model: str = ""
temperature: Optional[float] = None
max_tokens: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"messages_count": len(self.messages),
"model": self.model,
"temperature": self.temperature,
"max_tokens": self.max_tokens
})
return base
@dataclass
class AfterLLMInput(HookInput):
"""Input for AfterLLM hooks."""
messages: List[Dict[str, Any]] = field(default_factory=list)
response: str = ""
model: str = ""
tokens_used: int = 0
latency_ms: float = 0.0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"messages_count": len(self.messages),
"response_length": len(self.response),
"model": self.model,
"tokens_used": self.tokens_used,
"latency_ms": self.latency_ms
})
return base
@dataclass
class OnErrorInput(HookInput):
"""Input for OnError hooks."""
error_type: str = ""
error_message: str = ""
stack_trace: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"error_type": self.error_type,
"error_message": self.error_message,
"stack_trace": self.stack_trace,
"context": self.context
})
return base
@dataclass
class OnRetryInput(HookInput):
"""Input for OnRetry hooks."""
retry_count: int = 0
max_retries: int = 3
error_message: str = ""
operation: str = "" # tool_call, llm_request, etc.
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"retry_count": self.retry_count,
"max_retries": self.max_retries,
"error_message": self.error_message,
"operation": self.operation
})
return base
@@ -0,0 +1,336 @@
"""
Hook Registry for PraisonAI Agents.
Manages registration and lookup of hooks for different events.
"""
import logging
from typing import Dict, List, Optional, Callable, Union
from functools import wraps
from .types import (
HookEvent, HookDefinition, CommandHook, FunctionHook,
HookInput, HookResult
)
logger = logging.getLogger(__name__)
class HookRegistry:
"""
Registry for managing hooks.
Provides methods to register, unregister, and lookup hooks
for different events.
Example:
registry = HookRegistry()
# Register a function hook using decorator
@registry.on(HookEvent.BEFORE_TOOL)
def validate_tool(event_data):
if event_data.tool_name == "dangerous":
return HookResult.deny("Tool is dangerous")
return HookResult.allow()
# Register a command hook
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="python /path/to/validator.py",
matcher="write_*"
)
"""
def __init__(self, enabled: bool = True):
"""
Initialize the hook registry.
Args:
enabled: Whether hooks are enabled globally
"""
self._hooks: Dict[HookEvent, List[HookDefinition]] = {
event: [] for event in HookEvent
}
self._enabled = enabled
self._global_timeout = 60.0
@property
def enabled(self) -> bool:
"""Check if hooks are enabled."""
return self._enabled
@enabled.setter
def enabled(self, value: bool):
"""Enable or disable hooks."""
self._enabled = value
def register(self, hook: HookDefinition) -> str:
"""
Register a hook definition.
Args:
hook: The hook definition to register
Returns:
The hook ID
"""
self._hooks[hook.event].append(hook)
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
return hook.id
def register_function(
self,
event: HookEvent,
func: Callable[[HookInput], HookResult],
matcher: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
sequential: bool = False,
timeout: float = 60.0,
is_async: bool = False
) -> str:
"""
Register a Python function as a hook.
Args:
event: The event to hook
func: The function to call
matcher: Optional regex pattern to match targets
name: Optional name for the hook
description: Optional description
sequential: Whether to run sequentially
timeout: Timeout in seconds
is_async: Whether the function is async
Returns:
The hook ID
"""
hook = FunctionHook(
event=event,
func=func,
matcher=matcher,
name=name or func.__name__,
description=description,
sequential=sequential,
timeout=timeout,
is_async=is_async
)
return self.register(hook)
def register_command(
self,
event: HookEvent,
command: str,
matcher: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
sequential: bool = False,
timeout: float = 60.0,
env: Optional[Dict[str, str]] = None,
shell: bool = True
) -> str:
"""
Register a shell command as a hook.
Args:
event: The event to hook
command: The shell command to execute
matcher: Optional regex pattern to match targets
name: Optional name for the hook
description: Optional description
sequential: Whether to run sequentially
timeout: Timeout in seconds
env: Additional environment variables
shell: Whether to run in shell mode
Returns:
The hook ID
"""
hook = CommandHook(
event=event,
command=command,
matcher=matcher,
name=name,
description=description,
sequential=sequential,
timeout=timeout,
env=env or {},
shell=shell
)
return self.register(hook)
def on(
self,
event: HookEvent,
matcher: Optional[str] = None,
sequential: bool = False,
timeout: float = 60.0
) -> Callable:
"""
Decorator to register a function as a hook.
Args:
event: The event to hook
matcher: Optional regex pattern to match targets
sequential: Whether to run sequentially
timeout: Timeout in seconds
Returns:
Decorator function
Example:
@registry.on(HookEvent.BEFORE_TOOL)
def my_hook(event_data):
return HookResult.allow()
"""
def decorator(func: Callable[[HookInput], HookResult]) -> Callable:
import asyncio
is_async = asyncio.iscoroutinefunction(func)
self.register_function(
event=event,
func=func,
matcher=matcher,
sequential=sequential,
timeout=timeout,
is_async=is_async
)
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
def unregister(self, hook_id: str) -> bool:
"""
Unregister a hook by ID.
Args:
hook_id: The hook ID to unregister
Returns:
True if found and removed, False otherwise
"""
for event in HookEvent:
for i, hook in enumerate(self._hooks[event]):
if hook.id == hook_id:
self._hooks[event].pop(i)
logger.debug(f"Unregistered hook '{hook_id}'")
return True
return False
def get_hooks(
self,
event: HookEvent,
target: Optional[str] = None
) -> List[HookDefinition]:
"""
Get all hooks for an event, optionally filtered by target.
Args:
event: The event to get hooks for
target: Optional target to filter by (e.g., tool name)
Returns:
List of matching hook definitions
"""
if not self._enabled:
return []
hooks = self._hooks.get(event, [])
if target is None:
return [h for h in hooks if h.enabled]
return [h for h in hooks if h.enabled and h.matches(target)]
def has_hooks(self, event: HookEvent) -> bool:
"""Check if there are any hooks registered for an event."""
return bool(self.get_hooks(event))
def clear(self, event: Optional[HookEvent] = None):
"""
Clear all hooks or hooks for a specific event.
Args:
event: Optional event to clear hooks for
"""
if event is None:
for e in HookEvent:
self._hooks[e] = []
else:
self._hooks[event] = []
def list_hooks(self) -> Dict[str, List[Dict]]:
"""
List all registered hooks.
Returns:
Dictionary mapping event names to hook info
"""
result = {}
for event in HookEvent:
hooks = self._hooks[event]
if hooks:
result[event.value] = [
{
"id": h.id,
"name": h.name,
"type": "command" if isinstance(h, CommandHook) else "function",
"matcher": h.matcher,
"enabled": h.enabled,
"sequential": h.sequential
}
for h in hooks
]
return result
def enable_hook(self, hook_id: str) -> bool:
"""Enable a specific hook."""
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = True
return True
return False
def disable_hook(self, hook_id: str) -> bool:
"""Disable a specific hook."""
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = False
return True
return False
def set_global_timeout(self, timeout: float):
"""Set the global timeout for all hooks."""
self._global_timeout = timeout
def __len__(self) -> int:
"""Get total number of registered hooks."""
return sum(len(hooks) for hooks in self._hooks.values())
def __repr__(self) -> str:
return f"HookRegistry(enabled={self._enabled}, hooks={len(self)})"
# Global default registry
_default_registry: Optional[HookRegistry] = None
def get_default_registry() -> HookRegistry:
"""Get the default global hook registry."""
global _default_registry
if _default_registry is None:
_default_registry = HookRegistry()
return _default_registry
def set_default_registry(registry: HookRegistry):
"""Set the default global hook registry."""
global _default_registry
_default_registry = registry
@@ -0,0 +1,450 @@
"""
Hook Runner for PraisonAI Agents.
Executes hooks registered in the registry, supporting both
Python functions and shell commands.
"""
import os
import json
import time
import asyncio
import logging
import subprocess
from typing import List, Optional, Dict, Any
from .types import (
HookEvent, HookDefinition, CommandHook, FunctionHook,
HookInput, HookResult, HookExecutionResult
)
from .registry import HookRegistry
logger = logging.getLogger(__name__)
# Exit codes for command hooks
EXIT_CODE_SUCCESS = 0
EXIT_CODE_BLOCKING_ERROR = 2
EXIT_CODE_NON_BLOCKING_ERROR = 1
class HookRunner:
"""
Executes hooks from a registry.
Supports:
- Python function hooks (sync and async)
- Shell command hooks
- Sequential and parallel execution
- Timeout handling
- Result aggregation
Example:
registry = HookRegistry()
runner = HookRunner(registry)
# Execute hooks for an event
results = await runner.execute(
event=HookEvent.BEFORE_TOOL,
input_data=BeforeToolInput(
tool_name="write_file",
tool_input={"path": "/tmp/test.txt"}
)
)
# Check if any hook blocked
if runner.is_blocked(results):
print("Tool execution blocked by hook")
"""
def __init__(
self,
registry: Optional[HookRegistry] = None,
default_timeout: float = 60.0,
cwd: Optional[str] = None
):
"""
Initialize the hook runner.
Args:
registry: Hook registry to use
default_timeout: Default timeout for hooks
cwd: Working directory for command hooks
"""
self._registry = registry or HookRegistry()
self._default_timeout = default_timeout
self._cwd = cwd or os.getcwd()
@property
def registry(self) -> HookRegistry:
"""Get the hook registry."""
return self._registry
async def execute(
self,
event: HookEvent,
input_data: HookInput,
target: Optional[str] = None
) -> List[HookExecutionResult]:
"""
Execute all hooks for an event.
Args:
event: The event to execute hooks for
input_data: Input data for the hooks
target: Optional target to filter hooks (e.g., tool name)
Returns:
List of execution results
"""
hooks = self._registry.get_hooks(event, target)
if not hooks:
return []
# Separate sequential and parallel hooks
sequential_hooks = [h for h in hooks if h.sequential]
parallel_hooks = [h for h in hooks if not h.sequential]
results = []
# Execute parallel hooks first
if parallel_hooks:
parallel_results = await self._execute_parallel(
parallel_hooks, event, input_data
)
results.extend(parallel_results)
# Execute sequential hooks
if sequential_hooks:
sequential_results = await self._execute_sequential(
sequential_hooks, event, input_data
)
results.extend(sequential_results)
return results
def execute_sync(
self,
event: HookEvent,
input_data: HookInput,
target: Optional[str] = None
) -> List[HookExecutionResult]:
"""
Synchronous version of execute.
Args:
event: The event to execute hooks for
input_data: Input data for the hooks
target: Optional target to filter hooks
Returns:
List of execution results
"""
try:
loop = asyncio.get_running_loop()
# If we're in an async context, create a task
future = asyncio.ensure_future(self.execute(event, input_data, target))
return loop.run_until_complete(future)
except RuntimeError:
# No event loop running, create one
return asyncio.run(self.execute(event, input_data, target))
async def _execute_parallel(
self,
hooks: List[HookDefinition],
event: HookEvent,
input_data: HookInput
) -> List[HookExecutionResult]:
"""Execute hooks in parallel."""
tasks = [
self._execute_single(hook, event, input_data)
for hook in hooks
]
return await asyncio.gather(*tasks)
async def _execute_sequential(
self,
hooks: List[HookDefinition],
event: HookEvent,
input_data: HookInput
) -> List[HookExecutionResult]:
"""Execute hooks sequentially, passing modified input to next hook."""
results = []
current_input = input_data
for hook in hooks:
result = await self._execute_single(hook, event, current_input)
results.append(result)
# If hook modified the input, use it for next hook
if result.success and result.output and result.output.modified_input:
# Update input with modifications
for key, value in result.output.modified_input.items():
if hasattr(current_input, key):
setattr(current_input, key, value)
# Stop if hook blocked
if result.output and result.output.is_denied():
break
return results
async def _execute_single(
self,
hook: HookDefinition,
event: HookEvent,
input_data: HookInput
) -> HookExecutionResult:
"""Execute a single hook."""
start_time = time.time()
try:
if isinstance(hook, CommandHook):
return await self._execute_command_hook(hook, event, input_data, start_time)
elif isinstance(hook, FunctionHook):
return await self._execute_function_hook(hook, event, input_data, start_time)
else:
raise ValueError(f"Unknown hook type: {type(hook)}")
except Exception as e:
duration = (time.time() - start_time) * 1000
logger.warning(f"Hook '{hook.name}' failed: {e}")
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error=str(e),
duration_ms=duration
)
async def _execute_command_hook(
self,
hook: CommandHook,
event: HookEvent,
input_data: HookInput,
start_time: float
) -> HookExecutionResult:
"""Execute a command hook."""
timeout = hook.timeout or self._default_timeout
# Prepare environment
env = os.environ.copy()
env.update(hook.env)
env["PRAISON_PROJECT_DIR"] = self._cwd
env["PRAISON_EVENT"] = event.value
# Expand command variables
command = self._expand_command(hook.command, input_data)
try:
# Create subprocess
process = await asyncio.create_subprocess_shell(
command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=self._cwd
)
# Send input as JSON
input_json = json.dumps(input_data.to_dict()).encode()
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=input_json),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error=f"Hook timed out after {timeout}s",
duration_ms=duration
)
duration = (time.time() - start_time) * 1000
exit_code = process.returncode
stdout_str = stdout.decode() if stdout else ""
stderr_str = stderr.decode() if stderr else ""
# Parse output
output = self._parse_command_output(stdout_str, stderr_str, exit_code)
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=exit_code == EXIT_CODE_SUCCESS,
output=output,
stdout=stdout_str,
stderr=stderr_str,
exit_code=exit_code,
duration_ms=duration
)
except Exception as e:
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error=str(e),
duration_ms=duration
)
async def _execute_function_hook(
self,
hook: FunctionHook,
event: HookEvent,
input_data: HookInput,
start_time: float
) -> HookExecutionResult:
"""Execute a function hook."""
if hook.func is None:
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error="Hook function is None",
duration_ms=duration
)
timeout = hook.timeout or self._default_timeout
try:
if hook.is_async:
# Execute async function with timeout
result = await asyncio.wait_for(
hook.func(input_data),
timeout=timeout
)
else:
# Execute sync function in thread pool with timeout
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(None, hook.func, input_data),
timeout=timeout
)
duration = (time.time() - start_time) * 1000
# Normalize result
if result is None:
result = HookResult.allow()
elif not isinstance(result, HookResult):
result = HookResult.allow()
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=True,
output=result,
duration_ms=duration
)
except asyncio.TimeoutError:
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error=f"Hook timed out after {timeout}s",
duration_ms=duration
)
except Exception as e:
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error=str(e),
duration_ms=duration
)
def _expand_command(self, command: str, input_data: HookInput) -> str:
"""Expand variables in command string."""
return command.replace(
"$PRAISON_PROJECT_DIR", self._cwd
).replace(
"$PRAISON_CWD", input_data.cwd
)
def _parse_command_output(
self,
stdout: str,
stderr: str,
exit_code: int
) -> HookResult:
"""Parse command output into HookResult."""
# Try to parse JSON from stdout
if stdout.strip():
try:
data = json.loads(stdout.strip())
# Handle double-encoded JSON
if isinstance(data, str):
data = json.loads(data)
return HookResult(
decision=data.get("decision", "allow"),
reason=data.get("reason"),
modified_input=data.get("modified_input"),
additional_context=data.get("additional_context"),
suppress_output=data.get("suppress_output", False)
)
except json.JSONDecodeError:
pass
# Convert exit code to decision
if exit_code == EXIT_CODE_SUCCESS:
return HookResult(
decision="allow",
additional_context=stdout.strip() if stdout.strip() else None
)
elif exit_code == EXIT_CODE_BLOCKING_ERROR:
return HookResult(
decision="deny",
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
)
else:
return HookResult(
decision="allow",
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
)
@staticmethod
def is_blocked(results: List[HookExecutionResult]) -> bool:
"""Check if any hook blocked execution."""
for result in results:
if result.output and result.output.is_denied():
return True
return False
@staticmethod
def get_blocking_reason(results: List[HookExecutionResult]) -> Optional[str]:
"""Get the reason for blocking from results."""
for result in results:
if result.output and result.output.is_denied():
return result.output.reason
return None
@staticmethod
def aggregate_context(results: List[HookExecutionResult]) -> Optional[str]:
"""Aggregate additional context from all results."""
contexts = []
for result in results:
if result.output and result.output.additional_context:
contexts.append(result.output.additional_context)
return "\n".join(contexts) if contexts else None
@@ -0,0 +1,197 @@
"""
Hook Types for PraisonAI Agents.
Defines the core types, enums, and dataclasses for the hook system.
"""
import uuid
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Callable, Union, Literal
class HookEvent(str, Enum):
"""Event names for the hook system."""
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
BEFORE_AGENT = "before_agent"
AFTER_AGENT = "after_agent"
SESSION_START = "session_start"
SESSION_END = "session_end"
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
ON_ERROR = "on_error"
ON_RETRY = "on_retry"
# Decision types for hook outputs
HookDecision = Literal["allow", "deny", "block", "ask", None]
@dataclass
class HookInput:
"""Base hook input - common fields for all events."""
session_id: str
cwd: str
event_name: str
timestamp: str
agent_name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"session_id": self.session_id,
"cwd": self.cwd,
"event_name": self.event_name,
"timestamp": self.timestamp,
"agent_name": self.agent_name,
**self.extra
}
@dataclass
class HookOutput:
"""Base hook output - common fields for all events."""
proceed: bool = True
stop_reason: Optional[str] = None
suppress_output: bool = False
system_message: Optional[str] = None
decision: HookDecision = None
reason: Optional[str] = None
modified_data: Optional[Dict[str, Any]] = None
def is_blocking(self) -> bool:
"""Check if this output represents a blocking decision."""
return self.decision in ("block", "deny")
def should_stop(self) -> bool:
"""Check if execution should stop."""
return not self.proceed
def get_reason(self) -> str:
"""Get the effective reason for blocking or stopping."""
return self.reason or self.stop_reason or "No reason provided"
@dataclass
class HookResult:
"""Result from a hook execution."""
decision: HookDecision = "allow"
reason: Optional[str] = None
modified_input: Optional[Dict[str, Any]] = None
additional_context: Optional[str] = None
suppress_output: bool = False
@classmethod
def allow(cls, reason: Optional[str] = None) -> "HookResult":
"""Create an allow result."""
return cls(decision="allow", reason=reason)
@classmethod
def deny(cls, reason: str) -> "HookResult":
"""Create a deny result."""
return cls(decision="deny", reason=reason)
@classmethod
def block(cls, reason: str) -> "HookResult":
"""Create a block result."""
return cls(decision="block", reason=reason)
@classmethod
def ask(cls, reason: str) -> "HookResult":
"""Create an ask result (requires user confirmation)."""
return cls(decision="ask", reason=reason)
def is_allowed(self) -> bool:
"""Check if the result allows execution."""
return self.decision in ("allow", None)
def is_denied(self) -> bool:
"""Check if the result denies execution."""
return self.decision in ("deny", "block")
@dataclass
class HookDefinition:
"""Hook definition with matcher and configuration."""
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
event: HookEvent = HookEvent.BEFORE_TOOL
matcher: Optional[str] = None # Regex pattern to match tool names
sequential: bool = False # Execute hooks sequentially vs parallel
enabled: bool = True
name: Optional[str] = None
description: Optional[str] = None
timeout: float = 60.0 # Timeout in seconds
def matches(self, target: str) -> bool:
"""Check if this hook matches the target (tool name, etc.)."""
if self.matcher is None:
return True
import re
try:
return bool(re.match(self.matcher, target))
except re.error:
# Invalid regex, fall back to simple string match
return self.matcher in target
@dataclass
class CommandHook(HookDefinition):
"""Hook that executes a shell command."""
command: str = ""
shell: bool = True
env: Dict[str, str] = field(default_factory=dict)
def __post_init__(self):
if not self.name:
self.name = f"command_hook_{self.id}"
@dataclass
class FunctionHook(HookDefinition):
"""Hook that executes a Python function."""
func: Optional[Callable[[HookInput], HookResult]] = None
is_async: bool = False
def __post_init__(self):
if not self.name and self.func:
self.name = self.func.__name__
elif not self.name:
self.name = f"function_hook_{self.id}"
@dataclass
class HookExecutionResult:
"""Result of executing a single hook."""
hook_id: str
hook_name: str
event: HookEvent
success: bool
output: Optional[HookResult] = None
stdout: Optional[str] = None
stderr: Optional[str] = None
exit_code: Optional[int] = None
duration_ms: float = 0.0
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"hook_id": self.hook_id,
"hook_name": self.hook_name,
"event": self.event.value,
"success": self.success,
"output": self.output.__dict__ if self.output else None,
"stdout": self.stdout,
"stderr": self.stderr,
"exit_code": self.exit_code,
"duration_ms": self.duration_ms,
"error": self.error
}
# Type alias for hook functions
HookFunction = Callable[[HookInput], Union[HookResult, None]]
AsyncHookFunction = Callable[[HookInput], Any] # Returns Awaitable[HookResult]
@@ -0,0 +1,65 @@
"""
LSP Integration Module for PraisonAI Agents.
Provides Language Server Protocol integration for code intelligence:
- Diagnostics (errors, warnings)
- Code completion suggestions
- Go to definition
- Find references
- Hover information
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- LSP client only initialized when needed
- No overhead when not in use
Usage:
from praisonaiagents.lsp import LSPClient
# Create client for a language
client = LSPClient(language="python")
# Start the language server
await client.start()
# Get diagnostics for a file
diagnostics = await client.get_diagnostics("/path/to/file.py")
# Get completions at a position
completions = await client.get_completions("/path/to/file.py", line=10, character=5)
"""
__all__ = [
# Core client
"LSPClient",
# Data types
"Diagnostic",
"DiagnosticSeverity",
"CompletionItem",
"Location",
"Position",
"Range",
# Configuration
"LSPConfig",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "LSPClient":
from .client import LSPClient
return LSPClient
if name in ("Diagnostic", "DiagnosticSeverity", "CompletionItem",
"Location", "Position", "Range"):
from .types import (
Diagnostic, DiagnosticSeverity, CompletionItem,
Location, Position, Range
)
return locals()[name]
if name == "LSPConfig":
from .config import LSPConfig
return LSPConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,489 @@
"""
LSP Client for PraisonAI Agents.
Provides Language Server Protocol client implementation.
"""
import os
import json
import asyncio
import logging
from typing import Optional, List, Dict, Any
from pathlib import Path
from .types import (
Diagnostic, DiagnosticSeverity, CompletionItem, Location,
Position, Range, TextDocumentItem, TextDocumentIdentifier,
TextDocumentPositionParams
)
from .config import LSPConfig
logger = logging.getLogger(__name__)
class LSPClient:
"""
Language Server Protocol client.
Communicates with language servers to provide code intelligence
features like diagnostics, completions, and navigation.
Example:
client = LSPClient(language="python")
await client.start()
# Get diagnostics
diagnostics = await client.get_diagnostics("/path/to/file.py")
# Get completions
completions = await client.get_completions(
"/path/to/file.py", line=10, character=5
)
await client.stop()
"""
def __init__(
self,
language: str,
command: Optional[str] = None,
args: Optional[List[str]] = None,
root_uri: Optional[str] = None
):
"""
Initialize the LSP client.
Args:
language: Programming language (python, javascript, etc.)
command: Language server command (uses default if not specified)
args: Command arguments
root_uri: Workspace root URI
"""
self.config = LSPConfig(
language=language,
command=command,
args=args or [],
root_uri=root_uri
)
self._process: Optional[asyncio.subprocess.Process] = None
self._request_id = 0
self._pending_requests: Dict[int, asyncio.Future] = {}
self._diagnostics: Dict[str, List[Diagnostic]] = {}
self._initialized = False
self._reader_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
"""Check if the language server is running."""
return self._process is not None and self._process.returncode is None
async def start(self) -> bool:
"""
Start the language server.
Returns:
True if started successfully
"""
if self.is_running:
return True
try:
cmd = [self.config.command] + self.config.args
self._process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Start reader task
self._reader_task = asyncio.create_task(self._read_responses())
# Initialize the server
root_uri = self.config.root_uri or f"file://{os.getcwd()}"
result = await self._send_request("initialize", {
"processId": os.getpid(),
"rootUri": root_uri,
"capabilities": {
"textDocument": {
"completion": {"completionItem": {"snippetSupport": True}},
"hover": {},
"definition": {},
"references": {},
"publishDiagnostics": {}
}
},
"initializationOptions": self.config.initialization_options
})
if result:
await self._send_notification("initialized", {})
self._initialized = True
logger.info(f"LSP server started: {self.config.command}")
return True
return False
except FileNotFoundError:
logger.error(f"Language server not found: {self.config.command}")
return False
except Exception as e:
logger.error(f"Failed to start LSP server: {e}")
return False
async def stop(self):
"""Stop the language server."""
if not self.is_running:
return
try:
await self._send_request("shutdown", None)
await self._send_notification("exit", None)
except Exception:
pass
if self._reader_task:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
if self._process:
self._process.terminate()
try:
await asyncio.wait_for(self._process.wait(), timeout=5.0)
except asyncio.TimeoutError:
self._process.kill()
self._process = None
self._initialized = False
async def open_document(self, file_path: str, text: Optional[str] = None) -> bool:
"""
Open a document in the language server.
Args:
file_path: Path to the file
text: File content (reads from disk if not provided)
Returns:
True if successful
"""
if not self._initialized:
return False
if text is None:
try:
with open(file_path, "r") as f:
text = f.read()
except Exception as e:
logger.error(f"Failed to read file: {e}")
return False
uri = f"file://{os.path.abspath(file_path)}"
await self._send_notification("textDocument/didOpen", {
"textDocument": TextDocumentItem(
uri=uri,
language_id=self.config.language,
version=1,
text=text
).to_dict()
})
return True
async def close_document(self, file_path: str):
"""Close a document in the language server."""
if not self._initialized:
return
uri = f"file://{os.path.abspath(file_path)}"
await self._send_notification("textDocument/didClose", {
"textDocument": TextDocumentIdentifier(uri=uri).to_dict()
})
async def get_diagnostics(self, file_path: str) -> List[Diagnostic]:
"""
Get diagnostics for a file.
Args:
file_path: Path to the file
Returns:
List of diagnostics
"""
uri = f"file://{os.path.abspath(file_path)}"
return self._diagnostics.get(uri, [])
async def get_completions(
self,
file_path: str,
line: int,
character: int
) -> List[CompletionItem]:
"""
Get completions at a position.
Args:
file_path: Path to the file
line: Line number (0-indexed)
character: Character position (0-indexed)
Returns:
List of completion items
"""
if not self._initialized:
return []
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/completion", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character}
})
if result is None:
return []
items = result.get("items", result) if isinstance(result, dict) else result
return [
CompletionItem.from_dict(item)
for item in (items if isinstance(items, list) else [])
]
async def get_definition(
self,
file_path: str,
line: int,
character: int
) -> List[Location]:
"""
Get definition location(s) for a symbol.
Args:
file_path: Path to the file
line: Line number (0-indexed)
character: Character position (0-indexed)
Returns:
List of locations
"""
if not self._initialized:
return []
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/definition", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character}
})
if result is None:
return []
if isinstance(result, dict):
return [Location.from_dict(result)]
elif isinstance(result, list):
return [Location.from_dict(loc) for loc in result]
return []
async def get_references(
self,
file_path: str,
line: int,
character: int,
include_declaration: bool = True
) -> List[Location]:
"""
Get references to a symbol.
Args:
file_path: Path to the file
line: Line number (0-indexed)
character: Character position (0-indexed)
include_declaration: Include the declaration in results
Returns:
List of locations
"""
if not self._initialized:
return []
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/references", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
"context": {"includeDeclaration": include_declaration}
})
if result is None:
return []
return [Location.from_dict(loc) for loc in result]
async def get_hover(
self,
file_path: str,
line: int,
character: int
) -> Optional[str]:
"""
Get hover information for a position.
Args:
file_path: Path to the file
line: Line number (0-indexed)
character: Character position (0-indexed)
Returns:
Hover content as string
"""
if not self._initialized:
return None
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/hover", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character}
})
if result is None:
return None
contents = result.get("contents")
if isinstance(contents, str):
return contents
elif isinstance(contents, dict):
return contents.get("value", str(contents))
elif isinstance(contents, list):
return "\n".join(
c.get("value", str(c)) if isinstance(c, dict) else str(c)
for c in contents
)
return None
async def _send_request(self, method: str, params: Any) -> Any:
"""Send a request and wait for response."""
if not self.is_running:
return None
self._request_id += 1
request_id = self._request_id
message = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params
}
future = asyncio.get_event_loop().create_future()
self._pending_requests[request_id] = future
await self._send_message(message)
try:
result = await asyncio.wait_for(future, timeout=self.config.timeout)
return result
except asyncio.TimeoutError:
logger.warning(f"Request timed out: {method}")
self._pending_requests.pop(request_id, None)
return None
async def _send_notification(self, method: str, params: Any):
"""Send a notification (no response expected)."""
if not self.is_running:
return
message = {
"jsonrpc": "2.0",
"method": method,
"params": params
}
await self._send_message(message)
async def _send_message(self, message: Dict[str, Any]):
"""Send a JSON-RPC message."""
if self._process is None or self._process.stdin is None:
return
content = json.dumps(message)
header = f"Content-Length: {len(content)}\r\n\r\n"
self._process.stdin.write(header.encode() + content.encode())
await self._process.stdin.drain()
async def _read_responses(self):
"""Read responses from the language server."""
if self._process is None or self._process.stdout is None:
return
try:
while True:
# Read header
header = b""
while b"\r\n\r\n" not in header:
chunk = await self._process.stdout.read(1)
if not chunk:
return
header += chunk
# Parse content length
content_length = 0
for line in header.decode().split("\r\n"):
if line.startswith("Content-Length:"):
content_length = int(line.split(":")[1].strip())
break
if content_length == 0:
continue
# Read content
content = await self._process.stdout.read(content_length)
message = json.loads(content.decode())
await self._handle_message(message)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Error reading LSP response: {e}")
async def _handle_message(self, message: Dict[str, Any]):
"""Handle an incoming message."""
if "id" in message:
# Response to a request
request_id = message["id"]
if request_id in self._pending_requests:
future = self._pending_requests.pop(request_id)
if "error" in message:
future.set_exception(
RuntimeError(message["error"].get("message", "Unknown error"))
)
else:
future.set_result(message.get("result"))
elif "method" in message:
# Notification from server
method = message["method"]
params = message.get("params", {})
if method == "textDocument/publishDiagnostics":
uri = params.get("uri", "")
diagnostics = [
Diagnostic.from_dict(d)
for d in params.get("diagnostics", [])
]
self._diagnostics[uri] = diagnostics
@@ -0,0 +1,52 @@
"""
LSP Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, List
# Default language server commands
DEFAULT_SERVERS = {
"python": {
"command": "pylsp",
"args": [],
},
"javascript": {
"command": "typescript-language-server",
"args": ["--stdio"],
},
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
},
"rust": {
"command": "rust-analyzer",
"args": [],
},
"go": {
"command": "gopls",
"args": [],
},
}
@dataclass
class LSPConfig:
"""Configuration for LSP client."""
language: str
command: Optional[str] = None
args: List[str] = field(default_factory=list)
root_uri: Optional[str] = None
initialization_options: Dict = field(default_factory=dict)
timeout: float = 30.0
def __post_init__(self):
# Use default server if not specified
if self.command is None:
if self.language in DEFAULT_SERVERS:
server = DEFAULT_SERVERS[self.language]
self.command = server["command"]
self.args = server["args"]
else:
raise ValueError(f"No default server for language: {self.language}")
@@ -0,0 +1,162 @@
"""
LSP Types for PraisonAI Agents.
Defines the core types for Language Server Protocol integration.
"""
from enum import IntEnum
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
class DiagnosticSeverity(IntEnum):
"""Severity of a diagnostic."""
ERROR = 1
WARNING = 2
INFORMATION = 3
HINT = 4
@dataclass
class Position:
"""Position in a text document (0-indexed)."""
line: int
character: int
def to_dict(self) -> Dict[str, int]:
return {"line": self.line, "character": self.character}
@classmethod
def from_dict(cls, data: Dict[str, int]) -> "Position":
return cls(line=data["line"], character=data["character"])
@dataclass
class Range:
"""Range in a text document."""
start: Position
end: Position
def to_dict(self) -> Dict[str, Any]:
return {"start": self.start.to_dict(), "end": self.end.to_dict()}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Range":
return cls(
start=Position.from_dict(data["start"]),
end=Position.from_dict(data["end"])
)
@dataclass
class Location:
"""Location in a document."""
uri: str
range: Range
def to_dict(self) -> Dict[str, Any]:
return {"uri": self.uri, "range": self.range.to_dict()}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Location":
return cls(uri=data["uri"], range=Range.from_dict(data["range"]))
@dataclass
class Diagnostic:
"""Represents a diagnostic (error, warning, etc.)."""
range: Range
message: str
severity: DiagnosticSeverity = DiagnosticSeverity.ERROR
code: Optional[str] = None
source: Optional[str] = None
related_information: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"range": self.range.to_dict(),
"message": self.message,
"severity": self.severity.value,
"code": self.code,
"source": self.source
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Diagnostic":
return cls(
range=Range.from_dict(data["range"]),
message=data["message"],
severity=DiagnosticSeverity(data.get("severity", 1)),
code=data.get("code"),
source=data.get("source")
)
@dataclass
class CompletionItem:
"""Represents a completion item."""
label: str
kind: int = 1 # Text
detail: Optional[str] = None
documentation: Optional[str] = None
insert_text: Optional[str] = None
sort_text: Optional[str] = None
filter_text: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"label": self.label,
"kind": self.kind,
"detail": self.detail,
"documentation": self.documentation,
"insertText": self.insert_text
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "CompletionItem":
return cls(
label=data["label"],
kind=data.get("kind", 1),
detail=data.get("detail"),
documentation=data.get("documentation"),
insert_text=data.get("insertText")
)
@dataclass
class TextDocumentItem:
"""Represents a text document."""
uri: str
language_id: str
version: int
text: str
def to_dict(self) -> Dict[str, Any]:
return {
"uri": self.uri,
"languageId": self.language_id,
"version": self.version,
"text": self.text
}
@dataclass
class TextDocumentIdentifier:
"""Identifies a text document."""
uri: str
def to_dict(self) -> Dict[str, str]:
return {"uri": self.uri}
@dataclass
class TextDocumentPositionParams:
"""Parameters for text document position requests."""
text_document: TextDocumentIdentifier
position: Position
def to_dict(self) -> Dict[str, Any]:
return {
"textDocument": self.text_document.to_dict(),
"position": self.position.to_dict()
}
@@ -0,0 +1,77 @@
"""
Exec Policy Engine for PraisonAI Agents.
Provides policy-based execution control:
- Define rules for what agents can/cannot do
- Tool execution policies
- Resource access control
- Rate limiting and quotas
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Policies only evaluated when enabled
- No overhead when no policies defined
Usage:
from praisonaiagents.policy import PolicyEngine, Policy, PolicyRule
# Create a policy engine
engine = PolicyEngine()
# Add a policy
policy = Policy(
name="no_delete",
rules=[
PolicyRule(
action="deny",
resource="tool:delete_*",
reason="Delete operations are not allowed"
)
]
)
engine.add_policy(policy)
# Check if action is allowed
result = engine.check("tool:delete_file", context={})
"""
__all__ = [
# Core classes
"PolicyEngine",
"Policy",
"PolicyRule",
# Result types
"PolicyResult",
"PolicyAction",
# Configuration
"PolicyConfig",
# Convenience functions
"create_deny_tools_policy",
"create_allow_tools_policy",
"create_read_only_policy",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "PolicyEngine":
from .engine import PolicyEngine
return PolicyEngine
if name in ("Policy", "PolicyRule"):
from .policy import Policy, PolicyRule
return locals()[name]
if name in ("PolicyResult", "PolicyAction"):
from .types import PolicyResult, PolicyAction
return locals()[name]
if name == "PolicyConfig":
from .config import PolicyConfig
return PolicyConfig
if name in ("create_deny_tools_policy", "create_allow_tools_policy", "create_read_only_policy"):
from .engine import create_deny_tools_policy, create_allow_tools_policy, create_read_only_policy
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,15 @@
"""
Policy Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class PolicyConfig:
"""Configuration for the policy engine."""
enabled: bool = True
default_action: str = "allow" # Default action when no policy matches
log_decisions: bool = True
strict_mode: bool = False # If True, deny when no policy matches
@@ -0,0 +1,307 @@
"""
Policy Engine for PraisonAI Agents.
Manages and evaluates policies for execution control.
"""
import logging
from typing import Optional, List, Dict, Any
from .types import PolicyAction, PolicyResult
from .policy import Policy, PolicyRule
from .config import PolicyConfig
logger = logging.getLogger(__name__)
class PolicyEngine:
"""
Policy Engine for execution control.
Manages policies and evaluates them against resources
to determine what actions are allowed.
Example:
engine = PolicyEngine()
# Add a policy to block delete operations
engine.add_policy(Policy(
name="no_delete",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="Delete operations are not allowed"
)
]
))
# Check if action is allowed
result = engine.check("tool:delete_file", {})
if not result.allowed:
print(f"Denied: {result.reason}")
"""
def __init__(self, config: Optional[PolicyConfig] = None):
"""
Initialize the policy engine.
Args:
config: Engine configuration
"""
self.config = config or PolicyConfig()
self._policies: Dict[str, Policy] = {}
@property
def policies(self) -> List[Policy]:
"""Get all policies sorted by priority."""
return sorted(
self._policies.values(),
key=lambda p: p.priority,
reverse=True
)
def add_policy(self, policy: Policy):
"""
Add a policy to the engine.
Args:
policy: Policy to add
"""
self._policies[policy.name] = policy
logger.debug(f"Added policy: {policy.name}")
def remove_policy(self, name: str) -> bool:
"""
Remove a policy by name.
Args:
name: Policy name
Returns:
True if removed, False if not found
"""
if name in self._policies:
del self._policies[name]
logger.debug(f"Removed policy: {name}")
return True
return False
def get_policy(self, name: str) -> Optional[Policy]:
"""Get a policy by name."""
return self._policies.get(name)
def enable_policy(self, name: str) -> bool:
"""Enable a policy."""
policy = self._policies.get(name)
if policy:
policy.enabled = True
return True
return False
def disable_policy(self, name: str) -> bool:
"""Disable a policy."""
policy = self._policies.get(name)
if policy:
policy.enabled = False
return True
return False
def check(
self,
resource: str,
context: Optional[Dict[str, Any]] = None
) -> PolicyResult:
"""
Check if an action is allowed.
Args:
resource: Resource identifier (e.g., "tool:read_file")
context: Additional context for policy evaluation
Returns:
PolicyResult indicating if action is allowed
"""
if not self.config.enabled:
return PolicyResult.allow("Policy engine disabled")
context = context or {}
# Evaluate policies in priority order
for policy in self.policies:
result = policy.evaluate(resource, context)
if result is not None:
if self.config.log_decisions:
logger.info(
f"Policy decision: {result.action.value} for {resource} "
f"(policy: {result.policy_name}, reason: {result.reason})"
)
return result
# No policy matched - use default action
if self.config.strict_mode:
return PolicyResult.deny(
"No policy matched and strict mode is enabled"
)
return PolicyResult.allow("No policy matched")
def check_tool(
self,
tool_name: str,
tool_input: Optional[Dict[str, Any]] = None
) -> PolicyResult:
"""
Check if a tool execution is allowed.
Args:
tool_name: Name of the tool
tool_input: Tool input parameters
Returns:
PolicyResult
"""
return self.check(
f"tool:{tool_name}",
{"tool_name": tool_name, "tool_input": tool_input or {}}
)
def check_file(
self,
operation: str,
file_path: str
) -> PolicyResult:
"""
Check if a file operation is allowed.
Args:
operation: Operation type (read, write, delete)
file_path: Path to the file
Returns:
PolicyResult
"""
return self.check(
f"file:{operation}",
{"operation": operation, "file_path": file_path}
)
def clear(self):
"""Remove all policies."""
self._policies.clear()
def to_dict(self) -> Dict[str, Any]:
"""Convert engine state to dictionary."""
return {
"config": {
"enabled": self.config.enabled,
"default_action": self.config.default_action,
"strict_mode": self.config.strict_mode
},
"policies": [p.to_dict() for p in self.policies]
}
def load_from_dict(self, data: Dict[str, Any]):
"""Load policies from a dictionary."""
for policy_data in data.get("policies", []):
policy = Policy.from_dict(policy_data)
self.add_policy(policy)
# Convenience functions for common policies
def create_deny_tools_policy(
tool_patterns: List[str],
name: str = "deny_tools",
reason: str = "Tool not allowed"
) -> Policy:
"""
Create a policy that denies specific tools.
Args:
tool_patterns: List of tool name patterns to deny
name: Policy name
reason: Reason for denial
Returns:
Policy
"""
rules = [
PolicyRule(
action=PolicyAction.DENY,
resource=f"tool:{pattern}",
reason=reason,
name=f"deny_{pattern}"
)
for pattern in tool_patterns
]
return Policy(name=name, rules=rules)
def create_allow_tools_policy(
tool_patterns: List[str],
name: str = "allow_tools"
) -> Policy:
"""
Create a policy that allows specific tools.
Args:
tool_patterns: List of tool name patterns to allow
name: Policy name
Returns:
Policy
"""
rules = [
PolicyRule(
action=PolicyAction.ALLOW,
resource=f"tool:{pattern}",
name=f"allow_{pattern}"
)
for pattern in tool_patterns
]
return Policy(name=name, rules=rules, priority=10)
def create_read_only_policy(name: str = "read_only") -> Policy:
"""
Create a read-only policy that denies write operations.
Args:
name: Policy name
Returns:
Policy
"""
return Policy(
name=name,
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="file:write",
reason="Write operations not allowed in read-only mode",
name="deny_write"
),
PolicyRule(
action=PolicyAction.DENY,
resource="file:delete",
reason="Delete operations not allowed in read-only mode",
name="deny_delete"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*_write*",
reason="Write tools not allowed in read-only mode",
name="deny_write_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*_delete*",
reason="Delete tools not allowed in read-only mode",
name="deny_delete_tools"
),
],
priority=100
)
@@ -0,0 +1,174 @@
"""
Policy and PolicyRule for PraisonAI Agents.
Defines policies and rules for execution control.
"""
import re
import fnmatch
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Callable
from .types import PolicyAction, PolicyResult
@dataclass
class PolicyRule:
"""
A single rule within a policy.
Rules match against resources and contexts to determine
what action to take.
"""
action: PolicyAction
resource: Optional[str] = None # Pattern to match (supports wildcards)
condition: Optional[Callable[[Dict[str, Any]], bool]] = None
reason: Optional[str] = None
name: Optional[str] = None
priority: int = 0 # Higher priority rules are evaluated first
def matches(self, resource: str, context: Dict[str, Any]) -> bool:
"""
Check if this rule matches the given resource and context.
Args:
resource: Resource identifier (e.g., "tool:read_file")
context: Additional context for the check
Returns:
True if the rule matches
"""
# Check resource pattern
if self.resource:
if not self._match_pattern(self.resource, resource):
return False
# Check condition
if self.condition:
try:
if not self.condition(context):
return False
except Exception:
return False
return True
def _match_pattern(self, pattern: str, value: str) -> bool:
"""Match a pattern against a value (supports wildcards)."""
# Convert glob pattern to regex
if "*" in pattern or "?" in pattern:
return fnmatch.fnmatch(value, pattern)
return pattern == value
def evaluate(self, resource: str, context: Dict[str, Any]) -> Optional[PolicyResult]:
"""
Evaluate this rule against a resource.
Args:
resource: Resource identifier
context: Additional context
Returns:
PolicyResult if rule matches, None otherwise
"""
if not self.matches(resource, context):
return None
return PolicyResult(
allowed=self.action in (PolicyAction.ALLOW, PolicyAction.LOG),
action=self.action,
rule_name=self.name,
reason=self.reason
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"action": self.action.value,
"resource": self.resource,
"reason": self.reason,
"name": self.name,
"priority": self.priority
}
@dataclass
class Policy:
"""
A collection of rules that define execution policy.
Policies are evaluated in order, with higher priority rules
taking precedence.
"""
name: str
rules: List[PolicyRule] = field(default_factory=list)
description: Optional[str] = None
enabled: bool = True
priority: int = 0 # Higher priority policies are evaluated first
def add_rule(self, rule: PolicyRule):
"""Add a rule to this policy."""
self.rules.append(rule)
# Sort by priority (descending)
self.rules.sort(key=lambda r: r.priority, reverse=True)
def remove_rule(self, rule_name: str) -> bool:
"""Remove a rule by name."""
for i, rule in enumerate(self.rules):
if rule.name == rule_name:
self.rules.pop(i)
return True
return False
def evaluate(self, resource: str, context: Dict[str, Any]) -> Optional[PolicyResult]:
"""
Evaluate this policy against a resource.
Args:
resource: Resource identifier
context: Additional context
Returns:
PolicyResult if any rule matches, None otherwise
"""
if not self.enabled:
return None
for rule in self.rules:
result = rule.evaluate(resource, context)
if result is not None:
result.policy_name = self.name
return result
return None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"description": self.description,
"enabled": self.enabled,
"priority": self.priority,
"rules": [r.to_dict() for r in self.rules]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Policy":
"""Create a policy from a dictionary."""
rules = []
for rule_data in data.get("rules", []):
rules.append(PolicyRule(
action=PolicyAction(rule_data["action"]),
resource=rule_data.get("resource"),
reason=rule_data.get("reason"),
name=rule_data.get("name"),
priority=rule_data.get("priority", 0)
))
return cls(
name=data["name"],
rules=rules,
description=data.get("description"),
enabled=data.get("enabled", True),
priority=data.get("priority", 0)
)
@@ -0,0 +1,60 @@
"""
Policy Types for PraisonAI Agents.
Defines the core types for the policy engine.
"""
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
class PolicyAction(str, Enum):
"""Action to take when a policy matches."""
ALLOW = "allow"
DENY = "deny"
ASK = "ask" # Require user confirmation
LOG = "log" # Allow but log the action
RATE_LIMIT = "rate_limit"
@dataclass
class PolicyResult:
"""Result of a policy check."""
allowed: bool
action: PolicyAction
policy_name: Optional[str] = None
rule_name: Optional[str] = None
reason: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@classmethod
def allow(cls, reason: Optional[str] = None) -> "PolicyResult":
"""Create an allow result."""
return cls(allowed=True, action=PolicyAction.ALLOW, reason=reason)
@classmethod
def deny(cls, reason: str, policy_name: Optional[str] = None) -> "PolicyResult":
"""Create a deny result."""
return cls(
allowed=False,
action=PolicyAction.DENY,
policy_name=policy_name,
reason=reason
)
@classmethod
def ask(cls, reason: str) -> "PolicyResult":
"""Create an ask result (requires confirmation)."""
return cls(allowed=False, action=PolicyAction.ASK, reason=reason)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"allowed": self.allowed,
"action": self.action.value,
"policy_name": self.policy_name,
"rule_name": self.rule_name,
"reason": self.reason,
"metadata": self.metadata
}
@@ -0,0 +1,76 @@
"""Agent Skills module for PraisonAI Agents.
This module provides support for the open Agent Skills standard (agentskills.io),
enabling agents to load and use modular capabilities through SKILL.md files.
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Skills only loaded when explicitly enabled
- No auto-discovery at import time
Usage:
from praisonaiagents.skills import SkillManager, SkillProperties
manager = SkillManager()
manager.discover(["./skills"])
prompt_xml = manager.to_prompt()
"""
__all__ = [
# Models
"SkillProperties",
"SkillMetadata",
"ParseError",
"ValidationError",
# Parser
"parse_frontmatter",
"find_skill_md",
"read_properties",
# Validator
"validate",
"validate_metadata",
# Prompt
"to_prompt",
"generate_skills_xml",
"format_skill_for_prompt",
# Discovery
"discover_skills",
"get_default_skill_dirs",
# Loader
"SkillLoader",
# Manager
"SkillManager",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("SkillProperties", "SkillMetadata", "ParseError", "ValidationError"):
from .models import SkillProperties, SkillMetadata, ParseError, ValidationError
return locals()[name]
if name in ("parse_frontmatter", "find_skill_md", "read_properties"):
from .parser import parse_frontmatter, find_skill_md, read_properties
return locals()[name]
if name in ("validate", "validate_metadata", "_validate_name", "_validate_description", "_validate_compatibility"):
from .validator import validate, validate_metadata, _validate_name, _validate_description, _validate_compatibility
return locals()[name]
if name in ("to_prompt", "generate_skills_xml", "format_skill_for_prompt"):
from .prompt import to_prompt, generate_skills_xml, format_skill_for_prompt
return locals()[name]
if name in ("discover_skills", "get_default_skill_dirs"):
from .discovery import discover_skills, get_default_skill_dirs
return locals()[name]
if name == "SkillLoader":
from .loader import SkillLoader
return SkillLoader
if name == "SkillManager":
from .manager import SkillManager
return SkillManager
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,129 @@
"""Skill discovery and directory scanning."""
from pathlib import Path
from typing import List, Optional
from .parser import find_skill_md, read_properties
from .models import SkillProperties
def get_default_skill_dirs() -> List[Path]:
"""Get default skill directory locations.
Returns directories in precedence order (high to low):
1. Project: ./.praison/skills/ or ./.claude/skills/
2. User: ~/.praison/skills/
3. System: /etc/praison/skills/ (admin-managed)
Returns:
List of existing skill directories
"""
dirs = []
cwd = Path.cwd()
# Project-level directories
project_dirs = [
cwd / ".praison" / "skills",
cwd / ".claude" / "skills",
]
for d in project_dirs:
if d.exists() and d.is_dir():
dirs.append(d)
# User-level directory
user_dir = Path.home() / ".praison" / "skills"
if user_dir.exists() and user_dir.is_dir():
dirs.append(user_dir)
# System-level directory (Unix-like systems)
system_dir = Path("/etc/praison/skills")
if system_dir.exists() and system_dir.is_dir():
dirs.append(system_dir)
return dirs
def discover_skills(
skill_dirs: Optional[List[str]] = None,
include_defaults: bool = True,
) -> List[SkillProperties]:
"""Discover all valid skills in the given directories.
Args:
skill_dirs: List of directory paths to scan for skills.
Each directory should contain skill subdirectories.
include_defaults: Whether to include default skill directories
Returns:
List of SkillProperties for all valid skills found
"""
all_dirs = []
# Add explicit directories
if skill_dirs:
for d in skill_dirs:
path = Path(d).expanduser().resolve()
if path.exists() and path.is_dir():
all_dirs.append(path)
# Add default directories
if include_defaults:
all_dirs.extend(get_default_skill_dirs())
# Remove duplicates while preserving order
seen = set()
unique_dirs = []
for d in all_dirs:
if d not in seen:
seen.add(d)
unique_dirs.append(d)
skills = []
for parent_dir in unique_dirs:
# Each subdirectory in parent_dir might be a skill
try:
for item in parent_dir.iterdir():
if not item.is_dir():
continue
# Check if this directory contains a SKILL.md
skill_md = find_skill_md(item)
if skill_md is None:
continue
try:
props = read_properties(item)
skills.append(props)
except Exception:
# Skip invalid skills
continue
except PermissionError:
# Skip directories we can't read
continue
return skills
def discover_skill(skill_path: str) -> Optional[SkillProperties]:
"""Discover a single skill from a directory path.
Args:
skill_path: Path to a skill directory
Returns:
SkillProperties if valid, None otherwise
"""
path = Path(skill_path).expanduser().resolve()
if not path.exists() or not path.is_dir():
return None
skill_md = find_skill_md(path)
if skill_md is None:
return None
try:
return read_properties(path)
except Exception:
return None
@@ -0,0 +1,236 @@
"""Progressive skill loading for Agent Skills."""
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
from .parser import find_skill_md, read_properties, parse_frontmatter
from .models import SkillProperties, SkillMetadata
@dataclass
class LoadedSkill:
"""A fully or partially loaded skill.
Supports progressive disclosure:
- Level 1: metadata only (name, description, location)
- Level 2: full instructions (SKILL.md body)
- Level 3: resources (scripts, references, assets)
"""
properties: SkillProperties
instructions: Optional[str] = None
resources_loaded: bool = False
_scripts: dict = field(default_factory=dict)
_references: dict = field(default_factory=dict)
_assets: dict = field(default_factory=dict)
@property
def metadata(self) -> SkillMetadata:
"""Get lightweight metadata for system prompt."""
return SkillMetadata.from_properties(self.properties)
@property
def is_activated(self) -> bool:
"""Check if skill has been activated (instructions loaded)."""
return self.instructions is not None
def get_scripts(self) -> dict:
"""Get loaded scripts."""
return self._scripts
def get_references(self) -> dict:
"""Get loaded references."""
return self._references
def get_assets(self) -> dict:
"""Get loaded assets."""
return self._assets
class SkillLoader:
"""Loader for Agent Skills with progressive disclosure support.
Usage:
loader = SkillLoader()
# Level 1: Load metadata only
skill = loader.load_metadata("/path/to/skill")
# Level 2: Activate skill (load instructions)
loader.activate(skill)
# Level 3: Load specific resources
loader.load_scripts(skill)
loader.load_references(skill)
"""
def load_metadata(self, skill_path: str) -> Optional[LoadedSkill]:
"""Load skill metadata only (Level 1).
This is the minimal load for system prompt injection.
Only reads the YAML frontmatter, not the full SKILL.md body.
Args:
skill_path: Path to skill directory
Returns:
LoadedSkill with metadata only, or None if invalid
"""
path = Path(skill_path).expanduser().resolve()
if not path.exists() or not path.is_dir():
return None
try:
props = read_properties(path)
return LoadedSkill(properties=props)
except Exception:
return None
def activate(self, skill: LoadedSkill) -> bool:
"""Activate a skill by loading its full instructions (Level 2).
Args:
skill: LoadedSkill instance to activate
Returns:
True if activation succeeded
"""
if skill.is_activated:
return True
if skill.properties.path is None:
return False
skill_md = find_skill_md(skill.properties.path)
if skill_md is None:
return False
try:
content = skill_md.read_text()
_, body = parse_frontmatter(content)
skill.instructions = body
return True
except Exception:
return False
def load_scripts(self, skill: LoadedSkill) -> dict:
"""Load scripts from skill's scripts/ directory (Level 3).
Args:
skill: LoadedSkill instance
Returns:
Dict mapping script names to their contents
"""
if skill.properties.path is None:
return {}
scripts_dir = skill.properties.path / "scripts"
if not scripts_dir.exists() or not scripts_dir.is_dir():
return {}
scripts = {}
try:
for script_file in scripts_dir.iterdir():
if script_file.is_file():
try:
scripts[script_file.name] = script_file.read_text()
except Exception:
continue
except PermissionError:
pass
skill._scripts = scripts
return scripts
def load_references(self, skill: LoadedSkill) -> dict:
"""Load references from skill's references/ directory (Level 3).
Args:
skill: LoadedSkill instance
Returns:
Dict mapping reference names to their contents
"""
if skill.properties.path is None:
return {}
refs_dir = skill.properties.path / "references"
if not refs_dir.exists() or not refs_dir.is_dir():
return {}
refs = {}
try:
for ref_file in refs_dir.iterdir():
if ref_file.is_file():
try:
refs[ref_file.name] = ref_file.read_text()
except Exception:
continue
except PermissionError:
pass
skill._references = refs
return refs
def load_assets(self, skill: LoadedSkill) -> dict:
"""Load asset paths from skill's assets/ directory (Level 3).
Note: For binary assets, only paths are returned, not contents.
Args:
skill: LoadedSkill instance
Returns:
Dict mapping asset names to their paths
"""
if skill.properties.path is None:
return {}
assets_dir = skill.properties.path / "assets"
if not assets_dir.exists() or not assets_dir.is_dir():
return {}
assets = {}
try:
for asset_file in assets_dir.iterdir():
if asset_file.is_file():
assets[asset_file.name] = str(asset_file)
except PermissionError:
pass
skill._assets = assets
skill.resources_loaded = True
return assets
def load_all_resources(self, skill: LoadedSkill) -> None:
"""Load all resources for a skill (Level 3 complete).
Args:
skill: LoadedSkill instance
"""
self.load_scripts(skill)
self.load_references(skill)
self.load_assets(skill)
skill.resources_loaded = True
@classmethod
def load(cls, skill_path: str, activate: bool = False) -> Optional[LoadedSkill]:
"""Convenience method to load a skill.
Args:
skill_path: Path to skill directory
activate: Whether to also load instructions
Returns:
LoadedSkill or None if invalid
"""
loader = cls()
skill = loader.load_metadata(skill_path)
if skill and activate:
loader.activate(skill)
return skill
@@ -0,0 +1,189 @@
"""SkillManager for Agent Skills integration."""
from typing import List, Optional, Dict
from .models import SkillMetadata
from .discovery import discover_skills
from .loader import SkillLoader, LoadedSkill
from .prompt import generate_skills_xml
class SkillManager:
"""Manager for discovering, loading, and using Agent Skills.
This is the main entry point for Agent Skills integration.
Supports progressive disclosure and zero performance impact
when skills are not used.
Usage:
manager = SkillManager()
# Discover skills from directories
manager.discover(["./skills", "~/.praison/skills"])
# Get XML for system prompt
prompt_xml = manager.to_prompt()
# Activate a specific skill
skill = manager.get_skill("pdf-processing")
manager.activate(skill)
"""
def __init__(self):
"""Initialize the SkillManager."""
self._skills: Dict[str, LoadedSkill] = {}
self._loader = SkillLoader()
self._discovered = False
@property
def skills(self) -> List[LoadedSkill]:
"""Get all loaded skills."""
return list(self._skills.values())
@property
def skill_names(self) -> List[str]:
"""Get names of all loaded skills."""
return list(self._skills.keys())
def discover(
self,
skill_dirs: Optional[List[str]] = None,
include_defaults: bool = True,
) -> int:
"""Discover skills from directories.
Args:
skill_dirs: List of directory paths to scan
include_defaults: Whether to include default skill directories
Returns:
Number of skills discovered
"""
props_list = discover_skills(skill_dirs, include_defaults)
for props in props_list:
if props.name not in self._skills:
self._skills[props.name] = LoadedSkill(properties=props)
self._discovered = True
return len(props_list)
def add_skill(self, skill_path: str) -> Optional[LoadedSkill]:
"""Add a single skill from a directory path.
Args:
skill_path: Path to skill directory
Returns:
LoadedSkill if successful, None otherwise
"""
skill = self._loader.load_metadata(skill_path)
if skill:
self._skills[skill.properties.name] = skill
return skill
def get_skill(self, name: str) -> Optional[LoadedSkill]:
"""Get a skill by name.
Args:
name: Skill name
Returns:
LoadedSkill or None if not found
"""
return self._skills.get(name)
def activate(self, skill: LoadedSkill) -> bool:
"""Activate a skill (load its instructions).
Args:
skill: LoadedSkill to activate
Returns:
True if activation succeeded
"""
return self._loader.activate(skill)
def activate_by_name(self, name: str) -> bool:
"""Activate a skill by name.
Args:
name: Skill name
Returns:
True if activation succeeded
"""
skill = self.get_skill(name)
if skill is None:
return False
return self.activate(skill)
def get_available_skills(self) -> List[SkillMetadata]:
"""Get metadata for all available skills.
This is used for system prompt injection.
Returns:
List of SkillMetadata instances
"""
return [skill.metadata for skill in self._skills.values()]
def to_prompt(self) -> str:
"""Generate XML prompt for available skills.
Returns:
XML string with <available_skills> block
"""
metadata_list = self.get_available_skills()
return generate_skills_xml(metadata_list)
def get_instructions(self, name: str) -> Optional[str]:
"""Get instructions for a skill, activating if needed.
Args:
name: Skill name
Returns:
Skill instructions or None if not found
"""
skill = self.get_skill(name)
if skill is None:
return None
if not skill.is_activated:
self.activate(skill)
return skill.instructions
def load_resources(self, name: str) -> bool:
"""Load all resources for a skill.
Args:
name: Skill name
Returns:
True if resources loaded successfully
"""
skill = self.get_skill(name)
if skill is None:
return False
self._loader.load_all_resources(skill)
return True
def clear(self) -> None:
"""Clear all loaded skills."""
self._skills.clear()
self._discovered = False
def __len__(self) -> int:
"""Get number of loaded skills."""
return len(self._skills)
def __contains__(self, name: str) -> bool:
"""Check if a skill is loaded."""
return name in self._skills
def __iter__(self):
"""Iterate over loaded skills."""
return iter(self._skills.values())
@@ -0,0 +1,95 @@
"""Data models for Agent Skills."""
from dataclasses import dataclass, field
from typing import Optional
from pathlib import Path
class ParseError(Exception):
"""Raised when SKILL.md parsing fails."""
pass
class ValidationError(Exception):
"""Raised when skill validation fails."""
pass
@dataclass
class SkillProperties:
"""Properties parsed from a skill's SKILL.md frontmatter.
Attributes:
name: Skill name in kebab-case (required)
description: What the skill does and when the model should use it (required)
license: License for the skill (optional)
compatibility: Compatibility information for the skill (optional)
allowed_tools: Tool patterns the skill requires (optional, experimental)
metadata: Key-value pairs for client-specific properties (defaults to
empty dict; omitted from to_dict() output when empty)
path: Path to the skill directory (optional, added for PraisonAI)
"""
name: str
description: str
license: Optional[str] = None
compatibility: Optional[str] = None
allowed_tools: Optional[str] = None
metadata: dict = field(default_factory=dict)
path: Optional[Path] = None
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values and empty metadata."""
result = {"name": self.name, "description": self.description}
if self.license is not None:
result["license"] = self.license
if self.compatibility is not None:
result["compatibility"] = self.compatibility
if self.allowed_tools is not None:
result["allowed-tools"] = self.allowed_tools
if self.metadata:
result["metadata"] = self.metadata
return result
@dataclass
class SkillMetadata:
"""Lightweight skill metadata for system prompt injection.
This is the minimal information needed to include a skill in the
system prompt's <available_skills> block (~50-100 tokens per skill).
Attributes:
name: Skill name
description: What the skill does and when to use it
location: Absolute path to the SKILL.md file
"""
name: str
description: str
location: str
@classmethod
def from_properties(cls, props: SkillProperties) -> "SkillMetadata":
"""Create SkillMetadata from SkillProperties.
Args:
props: SkillProperties instance with path set
Returns:
SkillMetadata with location pointing to SKILL.md
"""
if props.path:
# Look for SKILL.md or skill.md
skill_md = props.path / "SKILL.md"
if not skill_md.exists():
skill_md = props.path / "skill.md"
location = str(skill_md)
else:
location = ""
return cls(
name=props.name,
description=props.description,
location=location
)
@@ -0,0 +1,165 @@
"""YAML frontmatter parsing for SKILL.md files."""
from pathlib import Path
from typing import Optional, Tuple
import re
from .models import ParseError, ValidationError, SkillProperties
def find_skill_md(skill_dir: Path) -> Optional[Path]:
"""Find the SKILL.md file in a skill directory.
Prefers SKILL.md (uppercase) but accepts skill.md (lowercase).
Args:
skill_dir: Path to the skill directory
Returns:
Path to the SKILL.md file, or None if not found
"""
for name in ("SKILL.md", "skill.md"):
path = skill_dir / name
if path.exists():
return path
return None
def parse_frontmatter(content: str) -> Tuple[dict, str]:
"""Parse YAML frontmatter from SKILL.md content.
Args:
content: Raw content of SKILL.md file
Returns:
Tuple of (metadata dict, markdown body)
Raises:
ParseError: If frontmatter is missing or invalid
"""
if not content.startswith("---"):
raise ParseError("SKILL.md must start with YAML frontmatter (---)")
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("SKILL.md frontmatter not properly closed with ---")
frontmatter_str = parts[1]
body = parts[2].strip()
try:
metadata = _parse_yaml(frontmatter_str)
except Exception as e:
raise ParseError(f"Invalid YAML in frontmatter: {e}")
if not isinstance(metadata, dict):
raise ParseError("SKILL.md frontmatter must be a YAML mapping")
# Convert metadata values to strings where needed
if "metadata" in metadata and isinstance(metadata["metadata"], dict):
metadata["metadata"] = {str(k): str(v) for k, v in metadata["metadata"].items()}
return metadata, body
def _parse_yaml(yaml_str: str) -> dict:
"""Parse YAML string to dictionary.
Uses a simple parser to avoid strictyaml dependency.
Falls back to yaml if available.
Args:
yaml_str: YAML string to parse
Returns:
Parsed dictionary
"""
# Try to use PyYAML if available
try:
import yaml
return yaml.safe_load(yaml_str) or {}
except ImportError:
pass
# Simple fallback parser for basic YAML
result = {}
current_key = None
current_dict = None
for line in yaml_str.strip().split("\n"):
if not line.strip():
continue
# Check for nested dict
if line.startswith(" ") and current_dict is not None:
match = re.match(r"^\s+(\w+):\s*(.*)$", line)
if match:
key, value = match.groups()
current_dict[key] = value.strip().strip('"').strip("'")
continue
# Top-level key
match = re.match(r"^(\S+):\s*(.*)$", line)
if match:
key, value = match.groups()
value = value.strip()
if not value:
# Could be start of nested dict
result[key] = {}
current_key = key
current_dict = result[key]
else:
result[key] = value.strip('"').strip("'")
current_dict = None
return result
def read_properties(skill_dir: Path) -> SkillProperties:
"""Read skill properties from SKILL.md frontmatter.
This function parses the frontmatter and returns properties.
It does NOT perform full validation. Use validate() for that.
Args:
skill_dir: Path to the skill directory
Returns:
SkillProperties with parsed metadata
Raises:
ParseError: If SKILL.md is missing or has invalid YAML
ValidationError: If required fields (name, description) are missing
"""
skill_dir = Path(skill_dir)
skill_md = find_skill_md(skill_dir)
if skill_md is None:
raise ParseError(f"SKILL.md not found in {skill_dir}")
content = skill_md.read_text()
metadata, _ = parse_frontmatter(content)
if "name" not in metadata:
raise ValidationError("Missing required field in frontmatter: name")
if "description" not in metadata:
raise ValidationError("Missing required field in frontmatter: description")
name = metadata["name"]
description = metadata["description"]
if not isinstance(name, str) or not name.strip():
raise ValidationError("Field 'name' must be a non-empty string")
if not isinstance(description, str) or not description.strip():
raise ValidationError("Field 'description' must be a non-empty string")
return SkillProperties(
name=name.strip(),
description=description.strip(),
license=metadata.get("license"),
compatibility=metadata.get("compatibility"),
allowed_tools=metadata.get("allowed-tools"),
metadata=metadata.get("metadata") or {},
path=skill_dir,
)
@@ -0,0 +1,85 @@
"""XML prompt generation for Agent Skills."""
from pathlib import Path
from typing import List
import html
from .models import SkillMetadata
from .parser import read_properties
def format_skill_for_prompt(skill: SkillMetadata) -> str:
"""Format a single skill as XML for system prompt.
Args:
skill: SkillMetadata instance
Returns:
XML string for the skill
"""
name = html.escape(skill.name)
description = html.escape(skill.description)
location = html.escape(skill.location)
return f""" <skill>
<name>{name}</name>
<description>{description}</description>
<location>{location}</location>
</skill>"""
def generate_skills_xml(skills: List[SkillMetadata], working_directory: str = None) -> str:
"""Generate XML block for available skills.
Args:
skills: List of SkillMetadata instances
working_directory: Current working directory for path resolution
Returns:
XML string with <available_skills> block
"""
import os
cwd = working_directory or os.getcwd()
if not skills:
return f"""<available_skills>
<working_directory>{html.escape(cwd)}</working_directory>
<note>When using run_skill_script, pass file paths relative to the working directory or as absolute paths. The tool will resolve relative paths automatically.</note>
</available_skills>"""
skill_entries = "\n".join(format_skill_for_prompt(s) for s in skills)
return f"""<available_skills>
<working_directory>{html.escape(cwd)}</working_directory>
<note>When using run_skill_script, pass file paths relative to the working directory or as absolute paths. The tool will resolve relative paths automatically.</note>
{skill_entries}
</available_skills>"""
def to_prompt(skill_dirs: List[Path]) -> str:
"""Generate prompt XML from skill directories.
This is the main entry point for generating the <available_skills>
block to inject into agent system prompts.
Args:
skill_dirs: List of paths to skill directories
Returns:
XML string with <available_skills> block
"""
skills = []
for skill_dir in skill_dirs:
skill_dir = Path(skill_dir)
if not skill_dir.exists() or not skill_dir.is_dir():
continue
try:
props = read_properties(skill_dir)
meta = SkillMetadata.from_properties(props)
skills.append(meta)
except Exception:
# Skip invalid skills silently
continue
return generate_skills_xml(skills)
@@ -0,0 +1,176 @@
"""Skill validation logic."""
import unicodedata
from pathlib import Path
from typing import Optional, List
from .models import ParseError
from .parser import find_skill_md, parse_frontmatter
MAX_SKILL_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
MAX_COMPATIBILITY_LENGTH = 500
ALLOWED_FIELDS = {
"name",
"description",
"license",
"allowed-tools",
"metadata",
"compatibility",
}
def _validate_name(name: str, skill_dir: Optional[Path]) -> List[str]:
"""Validate skill name format and directory match.
Skill names support i18n characters (Unicode letters) plus hyphens.
Names must be lowercase and cannot start/end with hyphens.
"""
errors = []
if not name or not isinstance(name, str) or not name.strip():
errors.append("Field 'name' must be a non-empty string")
return errors
name = unicodedata.normalize("NFKC", name.strip())
if len(name) > MAX_SKILL_NAME_LENGTH:
errors.append(
f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit "
f"({len(name)} chars)"
)
if name != name.lower():
errors.append(f"Skill name '{name}' must be lowercase")
if name.startswith("-") or name.endswith("-"):
errors.append("Skill name cannot start or end with a hyphen")
if "--" in name:
errors.append("Skill name cannot contain consecutive hyphens")
if not all(c.isalnum() or c == "-" for c in name):
errors.append(
f"Skill name '{name}' contains invalid characters. "
"Only letters, digits, and hyphens are allowed."
)
if skill_dir:
dir_name = unicodedata.normalize("NFKC", skill_dir.name)
if dir_name != name:
errors.append(
f"Directory name '{skill_dir.name}' must match skill name '{name}'"
)
return errors
def _validate_description(description: str) -> List[str]:
"""Validate description format."""
errors = []
if not description or not isinstance(description, str) or not description.strip():
errors.append("Field 'description' must be a non-empty string")
return errors
if len(description) > MAX_DESCRIPTION_LENGTH:
errors.append(
f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit "
f"({len(description)} chars)"
)
return errors
def _validate_compatibility(compatibility: str) -> List[str]:
"""Validate compatibility format."""
errors = []
if not isinstance(compatibility, str):
errors.append("Field 'compatibility' must be a string")
return errors
if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
errors.append(
f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit "
f"({len(compatibility)} chars)"
)
return errors
def _validate_metadata_fields(metadata: dict) -> List[str]:
"""Validate that only allowed fields are present."""
errors = []
extra_fields = set(metadata.keys()) - ALLOWED_FIELDS
if extra_fields:
errors.append(
f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}. "
f"Only {sorted(ALLOWED_FIELDS)} are allowed."
)
return errors
def validate_metadata(metadata: dict, skill_dir: Optional[Path] = None) -> List[str]:
"""Validate parsed skill metadata.
This is the core validation function that works on already-parsed metadata,
avoiding duplicate file I/O when called from the parser.
Args:
metadata: Parsed YAML frontmatter dictionary
skill_dir: Optional path to skill directory (for name-directory match check)
Returns:
List of validation error messages. Empty list means valid.
"""
errors = []
errors.extend(_validate_metadata_fields(metadata))
if "name" not in metadata:
errors.append("Missing required field in frontmatter: name")
else:
errors.extend(_validate_name(metadata["name"], skill_dir))
if "description" not in metadata:
errors.append("Missing required field in frontmatter: description")
else:
errors.extend(_validate_description(metadata["description"]))
if "compatibility" in metadata:
errors.extend(_validate_compatibility(metadata["compatibility"]))
return errors
def validate(skill_dir: Path) -> List[str]:
"""Validate a skill directory.
Args:
skill_dir: Path to the skill directory
Returns:
List of validation error messages. Empty list means valid.
"""
skill_dir = Path(skill_dir)
if not skill_dir.exists():
return [f"Path does not exist: {skill_dir}"]
if not skill_dir.is_dir():
return [f"Not a directory: {skill_dir}"]
skill_md = find_skill_md(skill_dir)
if skill_md is None:
return ["Missing required file: SKILL.md"]
try:
content = skill_md.read_text()
metadata, _ = parse_frontmatter(content)
except ParseError as e:
return [str(e)]
return validate_metadata(metadata, skill_dir)
@@ -0,0 +1,60 @@
"""
Extended Thinking Budgets Module for PraisonAI Agents.
Provides configurable thinking budgets for LLM reasoning:
- Token budgets for extended thinking
- Time budgets for reasoning
- Adaptive budget allocation
- Budget tracking and reporting
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Budgets only applied when configured
- No overhead when not in use
Usage:
from praisonaiagents.thinking import ThinkingBudget, ThinkingConfig
# Create a thinking budget
budget = ThinkingBudget(
max_tokens=16000,
max_time_seconds=60,
adaptive=True
)
# Apply to agent
agent = Agent(
instructions="...",
thinking_budget=budget
)
"""
__all__ = [
# Core classes
"ThinkingBudget",
"ThinkingConfig",
# Tracking
"ThinkingUsage",
"ThinkingTracker",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "ThinkingBudget":
from .budget import ThinkingBudget
return ThinkingBudget
if name == "ThinkingConfig":
from .config import ThinkingConfig
return ThinkingConfig
if name == "ThinkingUsage":
from .tracker import ThinkingUsage
return ThinkingUsage
if name == "ThinkingTracker":
from .tracker import ThinkingTracker
return ThinkingTracker
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,131 @@
"""
Thinking Budget for PraisonAI Agents.
Defines budget constraints for extended thinking.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any
from enum import Enum
class BudgetLevel(str, Enum):
"""Predefined budget levels."""
MINIMAL = "minimal" # 2000 tokens
LOW = "low" # 4000 tokens
MEDIUM = "medium" # 8000 tokens
HIGH = "high" # 16000 tokens
MAXIMUM = "maximum" # 32000 tokens
# Token allocations for each level
BUDGET_TOKENS = {
BudgetLevel.MINIMAL: 2000,
BudgetLevel.LOW: 4000,
BudgetLevel.MEDIUM: 8000,
BudgetLevel.HIGH: 16000,
BudgetLevel.MAXIMUM: 32000,
}
@dataclass
class ThinkingBudget:
"""
Budget constraints for extended thinking.
Controls how much thinking/reasoning the LLM can do
before producing a response.
"""
max_tokens: int = 8000
max_time_seconds: Optional[float] = None
adaptive: bool = True
level: Optional[BudgetLevel] = None
# Adaptive settings
min_tokens: int = 1000
complexity_multiplier: float = 1.0
def __post_init__(self):
# If level is specified, use its token allocation
if self.level:
self.max_tokens = BUDGET_TOKENS.get(self.level, self.max_tokens)
@classmethod
def from_level(cls, level: BudgetLevel, **kwargs) -> "ThinkingBudget":
"""Create a budget from a predefined level."""
return cls(
max_tokens=BUDGET_TOKENS[level],
level=level,
**kwargs
)
@classmethod
def minimal(cls) -> "ThinkingBudget":
"""Create a minimal budget."""
return cls.from_level(BudgetLevel.MINIMAL)
@classmethod
def low(cls) -> "ThinkingBudget":
"""Create a low budget."""
return cls.from_level(BudgetLevel.LOW)
@classmethod
def medium(cls) -> "ThinkingBudget":
"""Create a medium budget."""
return cls.from_level(BudgetLevel.MEDIUM)
@classmethod
def high(cls) -> "ThinkingBudget":
"""Create a high budget."""
return cls.from_level(BudgetLevel.HIGH)
@classmethod
def maximum(cls) -> "ThinkingBudget":
"""Create a maximum budget."""
return cls.from_level(BudgetLevel.MAXIMUM)
def get_tokens_for_complexity(self, complexity: float) -> int:
"""
Get token budget based on task complexity.
Args:
complexity: Complexity score (0.0 to 1.0)
Returns:
Adjusted token budget
"""
if not self.adaptive:
return self.max_tokens
# Scale between min and max based on complexity
range_tokens = self.max_tokens - self.min_tokens
adjusted = self.min_tokens + int(range_tokens * complexity * self.complexity_multiplier)
return min(adjusted, self.max_tokens)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"max_tokens": self.max_tokens,
"max_time_seconds": self.max_time_seconds,
"adaptive": self.adaptive,
"level": self.level.value if self.level else None,
"min_tokens": self.min_tokens,
"complexity_multiplier": self.complexity_multiplier
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ThinkingBudget":
"""Create from dictionary."""
level = None
if data.get("level"):
level = BudgetLevel(data["level"])
return cls(
max_tokens=data.get("max_tokens", 8000),
max_time_seconds=data.get("max_time_seconds"),
adaptive=data.get("adaptive", True),
level=level,
min_tokens=data.get("min_tokens", 1000),
complexity_multiplier=data.get("complexity_multiplier", 1.0)
)
@@ -0,0 +1,17 @@
"""
Thinking Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class ThinkingConfig:
"""Configuration for extended thinking."""
enabled: bool = True
default_budget_tokens: int = 8000
max_budget_tokens: int = 32000
default_time_seconds: Optional[float] = None
adaptive: bool = True # Adjust budget based on task complexity
log_thinking: bool = False # Log thinking process
@@ -0,0 +1,173 @@
"""
Thinking Tracker for PraisonAI Agents.
Tracks thinking usage and provides reporting.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from datetime import datetime
@dataclass
class ThinkingUsage:
"""Usage statistics for a single thinking session."""
tokens_used: int = 0
time_seconds: float = 0.0
budget_tokens: int = 0
budget_time: Optional[float] = None
complexity: float = 0.5
started_at: Optional[datetime] = None
ended_at: Optional[datetime] = None
@property
def tokens_remaining(self) -> int:
"""Get remaining token budget."""
return max(0, self.budget_tokens - self.tokens_used)
@property
def time_remaining(self) -> Optional[float]:
"""Get remaining time budget."""
if self.budget_time is None:
return None
return max(0.0, self.budget_time - self.time_seconds)
@property
def token_utilization(self) -> float:
"""Get token utilization percentage."""
if self.budget_tokens == 0:
return 0.0
return self.tokens_used / self.budget_tokens
@property
def is_over_budget(self) -> bool:
"""Check if over token budget."""
return self.tokens_used > self.budget_tokens
@property
def is_over_time(self) -> bool:
"""Check if over time budget."""
if self.budget_time is None:
return False
return self.time_seconds > self.budget_time
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"tokens_used": self.tokens_used,
"time_seconds": self.time_seconds,
"budget_tokens": self.budget_tokens,
"budget_time": self.budget_time,
"complexity": self.complexity,
"tokens_remaining": self.tokens_remaining,
"token_utilization": self.token_utilization,
"is_over_budget": self.is_over_budget
}
@dataclass
class ThinkingTracker:
"""
Tracks thinking usage across multiple sessions.
Provides aggregate statistics and reporting.
"""
sessions: List[ThinkingUsage] = field(default_factory=list)
total_tokens_used: int = 0
total_time_seconds: float = 0.0
def start_session(
self,
budget_tokens: int,
budget_time: Optional[float] = None,
complexity: float = 0.5
) -> ThinkingUsage:
"""
Start a new thinking session.
Args:
budget_tokens: Token budget for this session
budget_time: Optional time budget
complexity: Task complexity (0.0 to 1.0)
Returns:
ThinkingUsage for tracking
"""
usage = ThinkingUsage(
budget_tokens=budget_tokens,
budget_time=budget_time,
complexity=complexity,
started_at=datetime.now()
)
self.sessions.append(usage)
return usage
def end_session(
self,
usage: ThinkingUsage,
tokens_used: int,
time_seconds: float
):
"""
End a thinking session.
Args:
usage: The usage object from start_session
tokens_used: Actual tokens used
time_seconds: Actual time taken
"""
usage.tokens_used = tokens_used
usage.time_seconds = time_seconds
usage.ended_at = datetime.now()
self.total_tokens_used += tokens_used
self.total_time_seconds += time_seconds
@property
def session_count(self) -> int:
"""Get number of sessions."""
return len(self.sessions)
@property
def average_tokens_per_session(self) -> float:
"""Get average tokens per session."""
if not self.sessions:
return 0.0
return self.total_tokens_used / len(self.sessions)
@property
def average_time_per_session(self) -> float:
"""Get average time per session."""
if not self.sessions:
return 0.0
return self.total_time_seconds / len(self.sessions)
@property
def average_utilization(self) -> float:
"""Get average budget utilization."""
if not self.sessions:
return 0.0
return sum(s.token_utilization for s in self.sessions) / len(self.sessions)
@property
def over_budget_count(self) -> int:
"""Get number of sessions that went over budget."""
return sum(1 for s in self.sessions if s.is_over_budget)
def get_summary(self) -> Dict[str, Any]:
"""Get summary statistics."""
return {
"session_count": self.session_count,
"total_tokens_used": self.total_tokens_used,
"total_time_seconds": self.total_time_seconds,
"average_tokens_per_session": self.average_tokens_per_session,
"average_time_per_session": self.average_time_per_session,
"average_utilization": self.average_utilization,
"over_budget_count": self.over_budget_count
}
def clear(self):
"""Clear all tracking data."""
self.sessions.clear()
self.total_tokens_used = 0
self.total_time_seconds = 0.0
@@ -222,6 +222,14 @@ TOOL_MAPPINGS = {
# Unified Web Search (auto-fallback across providers)
'search_web': ('.web_search', None),
'get_available_providers': ('.web_search', None),
# Skill Tools (for Agent Skills script execution)
'run_skill_script': ('.skill_tools', None),
'read_skill_file': ('.skill_tools', None),
'list_skill_scripts': ('.skill_tools', None),
'create_skill_tools': ('.skill_tools', None),
'SkillTools': ('.skill_tools', 'SkillTools'),
'skill_tools': ('.skill_tools', None),
}
_instances = {} # Cache for class instances
@@ -259,11 +267,12 @@ def __getattr__(name: str) -> Any:
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
'crawl4ai_sync', 'crawl4ai_extract_sync',
'search_web', 'get_available_providers'
'search_web', 'get_available_providers',
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools'
]:
return getattr(module, name)
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools']:
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,280 @@
"""Tools for executing Agent Skills scripts.
This module provides tools for running scripts bundled with Agent Skills.
Skills are modular packages that extend agent capabilities through SKILL.md
files and optional scripts.
Usage:
from praisonaiagents.tools import run_skill_script
result = run_skill_script("./my-skill/scripts/skill.py", "./data.csv")
"""
import subprocess
import os
import logging
class SkillTools:
"""Tools for executing Agent Skills scripts."""
def __init__(self, working_directory: str = None):
"""Initialize SkillTools.
Args:
working_directory: Base directory for resolving relative paths.
Defaults to current working directory.
"""
self._working_directory = working_directory or os.getcwd()
@property
def working_directory(self) -> str:
"""Get the working directory for path resolution."""
return self._working_directory
@working_directory.setter
def working_directory(self, value: str):
"""Set the working directory for path resolution."""
self._working_directory = value
def run_skill_script(
self,
script_path: str,
args: str = "",
timeout: int = 60
) -> str:
"""
Execute a skill script from a skill's scripts/ directory.
Args:
script_path: Path to the script file (e.g., ./csv-analyzer/scripts/skill.py)
args: Arguments to pass to the script (e.g., file path to analyze)
timeout: Maximum execution time in seconds (default: 60)
Returns:
The output from running the script, or error message if failed
"""
try:
# Resolve the script path
script_path = os.path.expanduser(script_path)
if not os.path.isabs(script_path):
script_path = os.path.join(self._working_directory, script_path)
script_path = os.path.abspath(script_path)
if not os.path.exists(script_path):
return f"Error: Script not found at {script_path}"
# Resolve args paths relative to working directory
resolved_args = []
if args:
for arg in args.split():
# Skip flags
if arg.startswith('-'):
resolved_args.append(arg)
continue
# Try to resolve as a path relative to working directory
potential_path = os.path.join(self._working_directory, arg)
if os.path.exists(potential_path):
resolved_args.append(os.path.abspath(potential_path))
elif os.path.isabs(arg) and os.path.exists(arg):
# Already absolute and exists
resolved_args.append(arg)
else:
# Keep original arg if path doesn't exist
resolved_args.append(arg)
# Determine how to run the script based on extension
ext = os.path.splitext(script_path)[1].lower()
if ext == '.py':
cmd = ['python', script_path] + resolved_args
elif ext == '.sh':
cmd = ['bash', script_path] + resolved_args
elif ext == '.js':
cmd = ['node', script_path] + resolved_args
else:
# Try to run directly
cmd = [script_path] + resolved_args
logging.debug(f"Running skill script: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=self._working_directory
)
output = result.stdout
if result.stderr:
output += f"\nStderr: {result.stderr}"
if result.returncode != 0:
output += f"\nExit code: {result.returncode}"
return output if output else "Script executed successfully (no output)"
except subprocess.TimeoutExpired:
return f"Error: Script execution timed out after {timeout} seconds"
except FileNotFoundError as e:
return f"Error: Could not find interpreter or script: {str(e)}"
except PermissionError as e:
return f"Error: Permission denied: {str(e)}"
except Exception as e:
return f"Error executing script: {str(e)}"
def read_skill_file(
self,
skill_path: str,
file_path: str,
encoding: str = 'utf-8'
) -> str:
"""
Read a file from a skill directory.
Args:
skill_path: Path to the skill directory (e.g., ./csv-analyzer)
file_path: Relative path within the skill (e.g., SKILL.md, scripts/skill.py)
encoding: File encoding (default: utf-8)
Returns:
File contents or error message
"""
try:
# Resolve skill path
skill_path = os.path.expanduser(skill_path)
if not os.path.isabs(skill_path):
skill_path = os.path.join(self._working_directory, skill_path)
skill_path = os.path.abspath(skill_path)
if not os.path.exists(skill_path):
return f"Error: Skill directory not found at {skill_path}"
if not os.path.isdir(skill_path):
return f"Error: {skill_path} is not a directory"
# Resolve file path within skill
full_path = os.path.join(skill_path, file_path)
full_path = os.path.abspath(full_path)
# Security check: ensure file is within skill directory
if not full_path.startswith(skill_path):
return f"Error: Path traversal detected - {file_path} is outside skill directory"
if not os.path.exists(full_path):
return f"Error: File not found at {full_path}"
with open(full_path, 'r', encoding=encoding) as f:
return f.read()
except UnicodeDecodeError as e:
return f"Error: Could not decode file with {encoding} encoding: {str(e)}"
except PermissionError as e:
return f"Error: Permission denied reading file: {str(e)}"
except Exception as e:
return f"Error reading skill file: {str(e)}"
def list_skill_scripts(self, skill_path: str) -> str:
"""
List available scripts in a skill's scripts/ directory.
Args:
skill_path: Path to the skill directory
Returns:
JSON string with list of scripts or error message
"""
import json
try:
# Resolve skill path
skill_path = os.path.expanduser(skill_path)
if not os.path.isabs(skill_path):
skill_path = os.path.join(self._working_directory, skill_path)
skill_path = os.path.abspath(skill_path)
if not os.path.exists(skill_path):
return f"Error: Skill directory not found at {skill_path}"
scripts_dir = os.path.join(skill_path, "scripts")
if not os.path.exists(scripts_dir):
return json.dumps({"scripts": [], "message": "No scripts/ directory found"})
scripts = []
for item in os.listdir(scripts_dir):
item_path = os.path.join(scripts_dir, item)
if os.path.isfile(item_path):
ext = os.path.splitext(item)[1].lower()
if ext in ['.py', '.sh', '.js', '.rb', '.pl']:
scripts.append({
"name": item,
"path": os.path.join("scripts", item),
"type": ext[1:] # Remove the dot
})
return json.dumps({"scripts": scripts}, indent=2)
except Exception as e:
return f"Error listing skill scripts: {str(e)}"
# Create default instance for direct function access
_skill_tools = SkillTools()
def run_skill_script(script_path: str, args: str = "", timeout: int = 60) -> str:
"""
Execute a skill script from a skill's scripts/ directory.
Args:
script_path: Path to the script file (e.g., ./csv-analyzer/scripts/script.py)
args: Arguments to pass to the script (e.g., file path to analyze)
timeout: Maximum execution time in seconds (default: 60)
Returns:
The output from running the script, or error message if failed
"""
return _skill_tools.run_skill_script(script_path, args, timeout)
def read_skill_file(skill_path: str, file_path: str, encoding: str = 'utf-8') -> str:
"""
Read a file from a skill directory.
Args:
skill_path: Path to the skill directory (e.g., ./csv-analyzer)
file_path: Relative path within the skill (e.g., SKILL.md, scripts/script.py)
encoding: File encoding (default: utf-8)
Returns:
File contents or error message
"""
return _skill_tools.read_skill_file(skill_path, file_path, encoding)
def list_skill_scripts(skill_path: str) -> str:
"""
List available scripts in a skill's scripts/ directory.
Args:
skill_path: Path to the skill directory
Returns:
JSON string with list of scripts or error message
"""
return _skill_tools.list_skill_scripts(skill_path)
def create_skill_tools(working_directory: str = None) -> SkillTools:
"""
Create a SkillTools instance with a specific working directory.
This is useful when you need to resolve paths relative to a specific
directory rather than the current working directory.
Args:
working_directory: Base directory for resolving relative paths
Returns:
SkillTools instance
"""
return SkillTools(working_directory)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.1.21"
version = "0.2.0"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1 @@
"""Unit tests for background module."""
@@ -0,0 +1,401 @@
"""
Unit tests for the Background module.
Tests cover:
- BackgroundTask creation and status management
- BackgroundRunner task submission
- Task cancellation and timeout
- Concurrent execution
"""
import pytest
import asyncio
from datetime import datetime
from praisonaiagents.background.task import BackgroundTask, TaskStatus
from praisonaiagents.background.config import BackgroundConfig
from praisonaiagents.background.runner import BackgroundRunner
# =============================================================================
# BackgroundTask Tests
# =============================================================================
class TestBackgroundTask:
"""Tests for BackgroundTask class."""
def test_task_creation(self):
"""Test creating a task."""
task = BackgroundTask(name="test_task")
assert task.name == "test_task"
assert task.status == TaskStatus.PENDING
assert task.progress == 0.0
assert not task.is_running
assert not task.is_completed
def test_task_start(self):
"""Test starting a task."""
task = BackgroundTask(name="test")
task.start()
assert task.status == TaskStatus.RUNNING
assert task.is_running
assert task.started_at is not None
def test_task_complete(self):
"""Test completing a task."""
task = BackgroundTask(name="test")
task.start()
task.complete(result="success")
assert task.status == TaskStatus.COMPLETED
assert task.is_completed
assert task.is_successful
assert task.result == "success"
assert task.progress == 1.0
assert task.completed_at is not None
def test_task_fail(self):
"""Test failing a task."""
task = BackgroundTask(name="test")
task.start()
task.fail("Something went wrong")
assert task.status == TaskStatus.FAILED
assert task.is_completed
assert not task.is_successful
assert task.error == "Something went wrong"
def test_task_cancel(self):
"""Test cancelling a task."""
task = BackgroundTask(name="test")
task.start()
task.cancel()
assert task.status == TaskStatus.CANCELLED
assert task.is_completed
assert task.should_cancel()
def test_task_progress(self):
"""Test updating task progress."""
task = BackgroundTask(name="test")
task.start()
task.update_progress(0.5, "Halfway done")
assert task.progress == 0.5
assert task.metadata.get("last_message") == "Halfway done"
def test_task_progress_bounds(self):
"""Test that progress is bounded."""
task = BackgroundTask(name="test")
task.update_progress(-0.5)
assert task.progress == 0.0
task.update_progress(1.5)
assert task.progress == 1.0
def test_task_duration(self):
"""Test task duration calculation."""
task = BackgroundTask(name="test")
assert task.duration_seconds is None
task.start()
assert task.duration_seconds is not None
assert task.duration_seconds >= 0
def test_task_to_dict(self):
"""Test task serialization."""
task = BackgroundTask(name="test")
task.start()
task.complete("result")
data = task.to_dict()
assert data["name"] == "test"
assert data["status"] == "completed"
assert data["result"] == "result"
assert "created_at" in data
assert "started_at" in data
assert "completed_at" in data
# =============================================================================
# BackgroundConfig Tests
# =============================================================================
class TestBackgroundConfig:
"""Tests for BackgroundConfig class."""
def test_config_defaults(self):
"""Test default configuration."""
config = BackgroundConfig()
assert config.max_concurrent_tasks == 5
assert config.default_timeout is None
assert config.auto_cleanup
assert config.cleanup_delay == 300.0
def test_config_custom(self):
"""Test custom configuration."""
config = BackgroundConfig(
max_concurrent_tasks=10,
default_timeout=60.0,
auto_cleanup=False
)
assert config.max_concurrent_tasks == 10
assert config.default_timeout == 60.0
assert not config.auto_cleanup
# =============================================================================
# BackgroundRunner Tests
# =============================================================================
class TestBackgroundRunner:
"""Tests for BackgroundRunner class."""
@pytest.fixture
def runner(self):
"""Create a test runner."""
return BackgroundRunner()
@pytest.mark.asyncio
async def test_submit_sync_function(self, runner):
"""Test submitting a sync function."""
def sync_func(x):
return x * 2
task = await runner.submit(func=sync_func, args=(5,), name="double")
result = await task.wait(timeout=5.0)
assert result == 10
assert task.is_successful
@pytest.mark.asyncio
async def test_submit_async_function(self, runner):
"""Test submitting an async function."""
async def async_func(x):
await asyncio.sleep(0.1)
return x * 3
task = await runner.submit(func=async_func, args=(5,), name="triple")
result = await task.wait(timeout=5.0)
assert result == 15
assert task.is_successful
@pytest.mark.asyncio
async def test_submit_with_kwargs(self, runner):
"""Test submitting with keyword arguments."""
def func_with_kwargs(a, b=10):
return a + b
task = await runner.submit(
func=func_with_kwargs,
args=(5,),
kwargs={"b": 20}
)
result = await task.wait(timeout=5.0)
assert result == 25
@pytest.mark.asyncio
async def test_task_timeout(self, runner):
"""Test task timeout."""
async def slow_func():
await asyncio.sleep(10)
return "done"
task = await runner.submit(func=slow_func, timeout=0.1)
await asyncio.sleep(0.3)
assert task.status == TaskStatus.FAILED
assert "timed out" in task.error
@pytest.mark.asyncio
async def test_task_failure(self, runner):
"""Test task that raises exception."""
def failing_func():
raise ValueError("Test error")
task = await runner.submit(func=failing_func)
await asyncio.sleep(0.1)
assert task.status == TaskStatus.FAILED
assert "Test error" in task.error
@pytest.mark.asyncio
async def test_cancel_task(self, runner):
"""Test cancelling a task."""
async def long_running():
await asyncio.sleep(10)
return "done"
task = await runner.submit(func=long_running)
await asyncio.sleep(0.1)
result = await runner.cancel_task(task.id)
assert result
assert task.status == TaskStatus.CANCELLED
@pytest.mark.asyncio
async def test_get_task(self, runner):
"""Test getting a task by ID."""
task = await runner.submit(func=lambda: "test")
retrieved = runner.get_task(task.id)
assert retrieved is not None
assert retrieved.id == task.id
@pytest.mark.asyncio
async def test_list_tasks(self, runner):
"""Test listing tasks."""
await runner.submit(func=lambda: "a", name="task_a")
await runner.submit(func=lambda: "b", name="task_b")
await asyncio.sleep(0.2)
tasks = runner.list_tasks()
assert len(tasks) == 2
@pytest.mark.asyncio
async def test_list_tasks_by_status(self, runner):
"""Test listing tasks filtered by status."""
await runner.submit(func=lambda: "a")
await runner.submit(func=lambda: "b")
await asyncio.sleep(0.2)
completed = runner.list_tasks(status=TaskStatus.COMPLETED)
assert len(completed) == 2
@pytest.mark.asyncio
async def test_clear_completed(self, runner):
"""Test clearing completed tasks."""
await runner.submit(func=lambda: "a")
await runner.submit(func=lambda: "b")
await asyncio.sleep(0.2)
assert len(runner.tasks) == 2
runner.clear_completed()
assert len(runner.tasks) == 0
@pytest.mark.asyncio
async def test_on_complete_callback(self, runner):
"""Test on_complete callback."""
callback_called = []
def on_complete(task):
callback_called.append(task.id)
task = await runner.submit(
func=lambda: "result",
on_complete=on_complete
)
await task.wait(timeout=5.0)
assert task.id in callback_called
@pytest.mark.asyncio
async def test_concurrent_execution(self, runner):
"""Test concurrent task execution."""
results = []
async def task_func(n):
await asyncio.sleep(0.1)
results.append(n)
return n
tasks = []
for i in range(3):
task = await runner.submit(func=task_func, args=(i,))
tasks.append(task)
await runner.wait_all(timeout=5.0)
assert len(results) == 3
assert all(t.is_successful for t in tasks)
@pytest.mark.asyncio
async def test_max_concurrent_limit(self):
"""Test max concurrent tasks limit."""
config = BackgroundConfig(max_concurrent_tasks=2)
runner = BackgroundRunner(config=config)
execution_times = []
async def tracked_func(n):
execution_times.append((n, "start", asyncio.get_event_loop().time()))
await asyncio.sleep(0.2)
execution_times.append((n, "end", asyncio.get_event_loop().time()))
return n
tasks = []
for i in range(4):
task = await runner.submit(func=tracked_func, args=(i,))
tasks.append(task)
await runner.wait_all(timeout=5.0)
# All tasks should complete
assert all(t.is_successful for t in tasks)
@pytest.mark.asyncio
async def test_wait_all(self, runner):
"""Test waiting for all tasks."""
async def slow_func(n):
await asyncio.sleep(0.1 * n)
return n
for i in range(1, 4):
await runner.submit(func=slow_func, args=(i,))
tasks = await runner.wait_all(timeout=5.0)
assert len(tasks) == 3
assert all(t.is_completed for t in tasks)
@pytest.mark.asyncio
async def test_cancel_all(self, runner):
"""Test cancelling all tasks."""
async def long_func():
await asyncio.sleep(10)
for _ in range(3):
await runner.submit(func=long_func)
await asyncio.sleep(0.1)
await runner.cancel_all()
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
@pytest.mark.asyncio
async def test_stop_runner(self, runner):
"""Test stopping the runner."""
async def long_func():
await asyncio.sleep(10)
await runner.submit(func=long_func)
await asyncio.sleep(0.1)
await runner.stop()
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for checkpoints module."""
@@ -0,0 +1,417 @@
"""
Unit tests for the Checkpoints module.
Tests cover:
- CheckpointConfig creation and validation
- Checkpoint data types
- CheckpointService initialization
- Save, restore, and diff operations
"""
import pytest
import asyncio
import os
import tempfile
import shutil
from datetime import datetime
from praisonaiagents.checkpoints.types import (
CheckpointConfig, Checkpoint, CheckpointDiff, FileDiff,
CheckpointResult, CheckpointEvent
)
from praisonaiagents.checkpoints.service import CheckpointService
# =============================================================================
# CheckpointConfig Tests
# =============================================================================
class TestCheckpointConfig:
"""Tests for CheckpointConfig class."""
def test_config_creation(self):
"""Test creating a config."""
config = CheckpointConfig(workspace_dir="/tmp/test")
assert config.workspace_dir == "/tmp/test"
assert config.enabled
assert config.auto_checkpoint
assert config.max_checkpoints == 100
def test_config_expands_paths(self):
"""Test that paths are expanded."""
config = CheckpointConfig(
workspace_dir="~/test",
storage_dir="~/.praison/checkpoints"
)
assert not config.workspace_dir.startswith("~")
assert not config.storage_dir.startswith("~")
def test_config_default_storage(self):
"""Test default storage directory."""
config = CheckpointConfig(workspace_dir="/tmp/test")
assert ".praison/checkpoints" in config.storage_dir
def test_config_exclude_patterns(self):
"""Test default exclude patterns."""
config = CheckpointConfig(workspace_dir="/tmp/test")
assert ".git" in config.exclude_patterns
assert "__pycache__" in config.exclude_patterns
assert "node_modules" in config.exclude_patterns
def test_get_checkpoint_dir(self):
"""Test checkpoint directory generation."""
config = CheckpointConfig(workspace_dir="/tmp/test")
checkpoint_dir = config.get_checkpoint_dir()
assert config.storage_dir in checkpoint_dir
# Should be a hash-based subdirectory
assert len(os.path.basename(checkpoint_dir)) == 12
# =============================================================================
# Checkpoint Data Type Tests
# =============================================================================
class TestCheckpointTypes:
"""Tests for checkpoint data types."""
def test_checkpoint_creation(self):
"""Test creating a checkpoint."""
checkpoint = Checkpoint(
id="abc123def456",
short_id="abc123de",
message="Test checkpoint",
timestamp=datetime.now()
)
assert checkpoint.id == "abc123def456"
assert checkpoint.short_id == "abc123de"
assert checkpoint.message == "Test checkpoint"
def test_checkpoint_from_git_commit(self):
"""Test creating checkpoint from git commit."""
checkpoint = Checkpoint.from_git_commit(
commit_hash="abc123def456789",
message="Test message",
timestamp="2024-01-01T00:00:00"
)
assert checkpoint.id == "abc123def456789"
assert checkpoint.short_id == "abc123de"
assert checkpoint.message == "Test message"
def test_checkpoint_to_dict(self):
"""Test checkpoint serialization."""
checkpoint = Checkpoint(
id="abc123",
short_id="abc1",
message="Test",
timestamp=datetime(2024, 1, 1)
)
data = checkpoint.to_dict()
assert data["id"] == "abc123"
assert data["message"] == "Test"
assert "timestamp" in data
def test_file_diff(self):
"""Test FileDiff creation."""
diff = FileDiff(
path="test.py",
absolute_path="/tmp/test.py",
status="modified",
additions=10,
deletions=5
)
assert diff.path == "test.py"
assert diff.status == "modified"
assert diff.additions == 10
def test_checkpoint_diff(self):
"""Test CheckpointDiff creation."""
files = [
FileDiff("a.py", "/tmp/a.py", "added", 10, 0),
FileDiff("b.py", "/tmp/b.py", "modified", 5, 3)
]
diff = CheckpointDiff(
from_checkpoint="abc123",
to_checkpoint="def456",
files=files
)
assert diff.total_additions == 15
assert diff.total_deletions == 3
def test_checkpoint_result_ok(self):
"""Test successful checkpoint result."""
checkpoint = Checkpoint(
id="abc123",
short_id="abc1",
message="Test",
timestamp=datetime.now()
)
result = CheckpointResult.ok(checkpoint)
assert result.success
assert result.checkpoint == checkpoint
assert result.error is None
def test_checkpoint_result_fail(self):
"""Test failed checkpoint result."""
result = CheckpointResult.fail("Something went wrong")
assert not result.success
assert result.checkpoint is None
assert result.error == "Something went wrong"
# =============================================================================
# CheckpointService Tests
# =============================================================================
class TestCheckpointService:
"""Tests for CheckpointService class."""
@pytest.fixture
def temp_workspace(self):
"""Create a temporary workspace directory."""
workspace = tempfile.mkdtemp(prefix="praison_test_")
yield workspace
# Cleanup
if os.path.exists(workspace):
shutil.rmtree(workspace)
@pytest.fixture
def temp_storage(self):
"""Create a temporary storage directory."""
storage = tempfile.mkdtemp(prefix="praison_checkpoints_")
yield storage
# Cleanup
if os.path.exists(storage):
shutil.rmtree(storage)
def test_service_creation(self, temp_workspace, temp_storage):
"""Test creating a checkpoint service."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
assert service.workspace_dir == temp_workspace
assert not service.is_initialized
@pytest.mark.asyncio
async def test_service_initialize(self, temp_workspace, temp_storage):
"""Test initializing the service."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
result = await service.initialize()
assert result
assert service.is_initialized
assert os.path.exists(service.git_dir)
@pytest.mark.asyncio
async def test_service_save_checkpoint(self, temp_workspace, temp_storage):
"""Test saving a checkpoint."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Create a file
test_file = os.path.join(temp_workspace, "test.txt")
with open(test_file, "w") as f:
f.write("Hello, World!")
# Save checkpoint
result = await service.save("First checkpoint")
assert result.success
assert result.checkpoint is not None
assert result.checkpoint.message == "First checkpoint"
@pytest.mark.asyncio
async def test_service_save_no_changes(self, temp_workspace, temp_storage):
"""Test saving checkpoint with no changes."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Try to save without changes
result = await service.save("Empty checkpoint")
assert not result.success
assert "No changes" in result.error
@pytest.mark.asyncio
async def test_service_save_allow_empty(self, temp_workspace, temp_storage):
"""Test saving empty checkpoint when allowed."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Save empty checkpoint
result = await service.save("Empty checkpoint", allow_empty=True)
assert result.success
@pytest.mark.asyncio
async def test_service_restore(self, temp_workspace, temp_storage):
"""Test restoring to a checkpoint."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Create initial file
test_file = os.path.join(temp_workspace, "test.txt")
with open(test_file, "w") as f:
f.write("Version 1")
# Save checkpoint
result1 = await service.save("Version 1")
checkpoint_id = result1.checkpoint.id
# Modify file
with open(test_file, "w") as f:
f.write("Version 2")
# Restore to checkpoint
result = await service.restore(checkpoint_id)
assert result.success
# Verify file content
with open(test_file, "r") as f:
content = f.read()
assert content == "Version 1"
@pytest.mark.asyncio
async def test_service_list_checkpoints(self, temp_workspace, temp_storage):
"""Test listing checkpoints."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Create some checkpoints
test_file = os.path.join(temp_workspace, "test.txt")
with open(test_file, "w") as f:
f.write("v1")
await service.save("Checkpoint 1")
with open(test_file, "w") as f:
f.write("v2")
await service.save("Checkpoint 2")
# List checkpoints
checkpoints = await service.list_checkpoints()
# Should have initial + 2 checkpoints
assert len(checkpoints) >= 2
@pytest.mark.asyncio
async def test_service_diff(self, temp_workspace, temp_storage):
"""Test getting diff between checkpoints."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
# Create file and checkpoint
test_file = os.path.join(temp_workspace, "test.txt")
with open(test_file, "w") as f:
f.write("Hello")
await service.save("Initial")
# Modify file
with open(test_file, "w") as f:
f.write("Hello World")
# Get diff
diff = await service.diff()
assert diff.from_checkpoint != ""
assert len(diff.files) > 0
@pytest.mark.asyncio
async def test_service_protected_path(self, temp_storage):
"""Test that protected paths are rejected."""
service = CheckpointService(
workspace_dir=os.path.expanduser("~"),
storage_dir=temp_storage
)
result = await service.initialize()
assert not result
assert not service.is_initialized
@pytest.mark.asyncio
async def test_service_event_handlers(self, temp_workspace, temp_storage):
"""Test event handlers."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
events_received = []
def on_initialized(data):
events_received.append(("initialized", data))
def on_checkpoint(data):
events_received.append(("checkpoint", data))
service.on(CheckpointEvent.INITIALIZED, on_initialized)
service.on(CheckpointEvent.CHECKPOINT_CREATED, on_checkpoint)
await service.initialize()
test_file = os.path.join(temp_workspace, "test.txt")
with open(test_file, "w") as f:
f.write("test")
await service.save("Test")
assert len(events_received) == 2
assert events_received[0][0] == "initialized"
assert events_received[1][0] == "checkpoint"
@pytest.mark.asyncio
async def test_service_cleanup(self, temp_workspace, temp_storage):
"""Test service cleanup."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
await service.cleanup()
assert not service.is_initialized
@pytest.mark.asyncio
async def test_service_delete_all(self, temp_workspace, temp_storage):
"""Test deleting all checkpoints."""
service = CheckpointService(
workspace_dir=temp_workspace,
storage_dir=temp_storage
)
await service.initialize()
checkpoint_dir = service.checkpoint_dir
assert os.path.exists(checkpoint_dir)
result = await service.delete_all()
assert result
assert not os.path.exists(checkpoint_dir)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for compaction module."""
@@ -0,0 +1,314 @@
"""
Unit tests for the Compaction module.
Tests cover:
- CompactionConfig settings
- CompactionStrategy enum
- CompactionResult properties
- ContextCompactor operations
"""
import pytest
from praisonaiagents.compaction.config import CompactionConfig
from praisonaiagents.compaction.strategy import CompactionStrategy
from praisonaiagents.compaction.result import CompactionResult
from praisonaiagents.compaction.compactor import ContextCompactor
# =============================================================================
# CompactionConfig Tests
# =============================================================================
class TestCompactionConfig:
"""Tests for CompactionConfig class."""
def test_config_defaults(self):
"""Test default configuration."""
config = CompactionConfig()
assert config.enabled
assert config.max_tokens == 8000
assert config.target_tokens == 6000
assert config.preserve_system
assert config.preserve_recent == 5
def test_config_custom(self):
"""Test custom configuration."""
config = CompactionConfig(
enabled=False,
max_tokens=16000,
preserve_recent=10
)
assert not config.enabled
assert config.max_tokens == 16000
assert config.preserve_recent == 10
# =============================================================================
# CompactionStrategy Tests
# =============================================================================
class TestCompactionStrategy:
"""Tests for CompactionStrategy enum."""
def test_strategy_values(self):
"""Test strategy enum values."""
assert CompactionStrategy.TRUNCATE.value == "truncate"
assert CompactionStrategy.SUMMARIZE.value == "summarize"
assert CompactionStrategy.SLIDING.value == "sliding"
assert CompactionStrategy.SMART.value == "smart"
def test_strategy_from_string(self):
"""Test creating strategy from string."""
strategy = CompactionStrategy("truncate")
assert strategy == CompactionStrategy.TRUNCATE
# =============================================================================
# CompactionResult Tests
# =============================================================================
class TestCompactionResult:
"""Tests for CompactionResult class."""
def test_result_creation(self):
"""Test creating a result."""
result = CompactionResult(
original_tokens=10000,
compacted_tokens=6000,
messages_removed=5,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
assert result.original_tokens == 10000
assert result.compacted_tokens == 6000
def test_result_tokens_saved(self):
"""Test tokens saved calculation."""
result = CompactionResult(
original_tokens=10000,
compacted_tokens=6000,
messages_removed=5,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
assert result.tokens_saved == 4000
def test_result_compression_ratio(self):
"""Test compression ratio calculation."""
result = CompactionResult(
original_tokens=10000,
compacted_tokens=5000,
messages_removed=5,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
assert result.compression_ratio == 0.5
def test_result_was_compacted(self):
"""Test was_compacted property."""
result = CompactionResult(
original_tokens=10000,
compacted_tokens=6000,
messages_removed=5,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
assert result.was_compacted
def test_result_not_compacted(self):
"""Test when not compacted."""
result = CompactionResult(
original_tokens=5000,
compacted_tokens=5000,
messages_removed=0,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
assert not result.was_compacted
def test_result_to_dict(self):
"""Test result serialization."""
result = CompactionResult(
original_tokens=10000,
compacted_tokens=6000,
messages_removed=5,
messages_kept=10,
strategy_used=CompactionStrategy.TRUNCATE
)
data = result.to_dict()
assert data["original_tokens"] == 10000
assert data["tokens_saved"] == 4000
assert data["strategy_used"] == "truncate"
# =============================================================================
# ContextCompactor Tests
# =============================================================================
class TestContextCompactor:
"""Tests for ContextCompactor class."""
@pytest.fixture
def compactor(self):
"""Create a test compactor."""
return ContextCompactor(max_tokens=100, preserve_recent=2)
@pytest.fixture
def messages(self):
"""Create test messages."""
return [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
{"role": "user", "content": "What's the weather like?"},
{"role": "assistant", "content": "I don't have access to weather data."},
]
def test_compactor_creation(self, compactor):
"""Test creating a compactor."""
assert compactor.max_tokens == 100
assert compactor.preserve_recent == 2
def test_compactor_estimate_tokens(self, compactor):
"""Test token estimation."""
text = "Hello world" # 11 chars
tokens = compactor.estimate_tokens(text)
assert tokens == 2 # 11 // 4
def test_compactor_count_message_tokens(self, compactor):
"""Test message token counting."""
message = {"role": "user", "content": "Hello world"}
tokens = compactor.count_message_tokens(message)
assert tokens == 2
def test_compactor_count_total_tokens(self, compactor, messages):
"""Test total token counting."""
tokens = compactor.count_total_tokens(messages)
assert tokens > 0
def test_compactor_needs_compaction_true(self):
"""Test needs_compaction when over limit."""
# Create compactor with very low limit
compactor = ContextCompactor(max_tokens=10, preserve_recent=2)
messages = [
{"role": "user", "content": "This is a very long message that should exceed the token limit."},
{"role": "assistant", "content": "This is another long response that adds more tokens."},
]
needs = compactor.needs_compaction(messages)
assert needs
def test_compactor_needs_compaction_false(self):
"""Test needs_compaction when under limit."""
compactor = ContextCompactor(max_tokens=10000)
messages = [{"role": "user", "content": "Hi"}]
needs = compactor.needs_compaction(messages)
assert not needs
def test_compactor_compact_not_needed(self):
"""Test compact when not needed."""
compactor = ContextCompactor(max_tokens=10000)
messages = [{"role": "user", "content": "Hi"}]
compacted, result = compactor.compact(messages)
assert len(compacted) == len(messages)
assert not result.was_compacted
def test_compactor_compact_truncate(self):
"""Test truncate strategy."""
compactor = ContextCompactor(max_tokens=10, preserve_recent=1)
compactor.strategy = CompactionStrategy.TRUNCATE
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "This is a very long message that should exceed the token limit."},
{"role": "assistant", "content": "This is another long response."},
{"role": "user", "content": "More content here."},
]
compacted, result = compactor.compact(messages)
assert len(compacted) < len(messages)
assert result.was_compacted
assert result.strategy_used == CompactionStrategy.TRUNCATE
def test_compactor_compact_sliding(self, compactor, messages):
"""Test sliding window strategy."""
compactor.strategy = CompactionStrategy.SLIDING
compacted, result = compactor.compact(messages)
assert result.strategy_used == CompactionStrategy.SLIDING
def test_compactor_compact_summarize(self, compactor, messages):
"""Test summarize strategy."""
compactor.strategy = CompactionStrategy.SUMMARIZE
compacted, result = compactor.compact(messages)
assert result.strategy_used == CompactionStrategy.SUMMARIZE
def test_compactor_compact_smart(self, compactor, messages):
"""Test smart strategy."""
compactor.strategy = CompactionStrategy.SMART
compacted, result = compactor.compact(messages)
assert result.strategy_used == CompactionStrategy.SMART
def test_compactor_preserves_system(self, compactor, messages):
"""Test that system messages are preserved."""
compacted, _ = compactor.compact(messages)
system_msgs = [m for m in compacted if m.get("role") == "system"]
assert len(system_msgs) >= 1
def test_compactor_preserves_recent(self, compactor, messages):
"""Test that recent messages are preserved."""
compacted, _ = compactor.compact(messages)
# Should have at least preserve_recent messages
non_system = [m for m in compacted if m.get("role") != "system"]
assert len(non_system) >= min(compactor.preserve_recent, len(messages) - 1)
def test_compactor_get_stats(self, compactor, messages):
"""Test get_stats method."""
stats = compactor.get_stats(messages)
assert "message_count" in stats
assert "total_tokens" in stats
assert "needs_compaction" in stats
assert stats["message_count"] == len(messages)
def test_compactor_multipart_content(self, compactor):
"""Test handling multipart content."""
message = {
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "World"}
]
}
tokens = compactor.count_message_tokens(message)
assert tokens > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for PraisonAI Agents evaluation framework."""
@@ -0,0 +1,146 @@
"""Unit tests for BaseEvaluator class."""
import pytest
import tempfile
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from praisonaiagents.eval.base import BaseEvaluator
class ConcreteEvaluator(BaseEvaluator):
"""Concrete implementation for testing."""
def run(self, **kwargs):
return {"test": "result"}
class TestBaseEvaluator:
"""Tests for BaseEvaluator abstract class."""
def test_init_default_values(self):
"""Test default initialization values."""
evaluator = ConcreteEvaluator()
assert evaluator.name.startswith("eval_")
assert evaluator.eval_id is not None
assert len(evaluator.eval_id) == 8
assert evaluator.save_results_path is None
assert evaluator.verbose is False
def test_init_custom_values(self):
"""Test initialization with custom values."""
evaluator = ConcreteEvaluator(
name="my_eval",
save_results_path="/tmp/results.json",
verbose=True
)
assert evaluator.name == "my_eval"
assert evaluator.save_results_path == "/tmp/results.json"
assert evaluator.verbose is True
def test_eval_id_unique(self):
"""Test that each evaluator gets a unique ID."""
eval1 = ConcreteEvaluator()
eval2 = ConcreteEvaluator()
assert eval1.eval_id != eval2.eval_id
def test_before_run_verbose(self):
"""Test before_run hook with verbose logging."""
evaluator = ConcreteEvaluator(verbose=True)
with patch('praisonaiagents.eval.base.logger') as mock_logger:
evaluator.before_run()
mock_logger.info.assert_called()
def test_after_run_verbose(self):
"""Test after_run hook with verbose logging."""
evaluator = ConcreteEvaluator(verbose=True)
with patch('praisonaiagents.eval.base.logger') as mock_logger:
evaluator.after_run({"result": "test"})
mock_logger.info.assert_called()
def test_save_result_to_file(self):
"""Test saving results to file."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/results.json"
evaluator = ConcreteEvaluator(
name="test_save",
save_results_path=path
)
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
evaluator._save_result(result)
assert Path(path).exists()
with open(path) as f:
data = json.load(f)
assert data["score"] == 8.0
def test_save_result_with_placeholders(self):
"""Test saving results with path placeholders."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/{{name}}_{{eval_id}}.json"
evaluator = ConcreteEvaluator(
name="test_eval",
save_results_path=path
)
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
evaluator._save_result(result)
expected_path = f"{tmpdir}/test_eval_{evaluator.eval_id}.json"
assert Path(expected_path).exists()
def test_save_result_creates_directories(self):
"""Test that save creates parent directories."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/nested/dir/results.json"
evaluator = ConcreteEvaluator(save_results_path=path)
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
evaluator._save_result(result)
assert Path(path).exists()
def test_run_abstract_method(self):
"""Test that run method works in concrete implementation."""
evaluator = ConcreteEvaluator()
result = evaluator.run()
assert result == {"test": "result"}
@pytest.mark.asyncio
async def test_run_async_default(self):
"""Test default async run calls sync run."""
evaluator = ConcreteEvaluator()
result = await evaluator.run_async()
assert result == {"test": "result"}
@pytest.mark.asyncio
async def test_async_before_run(self):
"""Test async before_run hook."""
evaluator = ConcreteEvaluator(verbose=True)
with patch('praisonaiagents.eval.base.logger') as mock_logger:
await evaluator.async_before_run()
mock_logger.info.assert_called()
@pytest.mark.asyncio
async def test_async_after_run(self):
"""Test async after_run hook."""
evaluator = ConcreteEvaluator(verbose=True)
with patch('praisonaiagents.eval.base.logger') as mock_logger:
await evaluator.async_after_run({"result": "test"})
mock_logger.info.assert_called()
def test_repr(self):
"""Test string representation."""
evaluator = ConcreteEvaluator(name="test_eval")
repr_str = repr(evaluator)
assert "ConcreteEvaluator" in repr_str
assert "test_eval" in repr_str
assert evaluator.eval_id in repr_str
@@ -0,0 +1,425 @@
"""Unit tests for evaluator classes."""
import pytest
import time
from unittest.mock import MagicMock, patch, AsyncMock
from praisonaiagents.eval.accuracy import AccuracyEvaluator
from praisonaiagents.eval.performance import PerformanceEvaluator
from praisonaiagents.eval.reliability import ReliabilityEvaluator
from praisonaiagents.eval.criteria import CriteriaEvaluator
from praisonaiagents.eval.results import (
AccuracyResult,
PerformanceResult,
ReliabilityResult,
CriteriaResult,
)
class TestAccuracyEvaluator:
"""Tests for AccuracyEvaluator class."""
def test_init_with_func(self):
"""Test initialization with function."""
func = lambda x: "test output"
evaluator = AccuracyEvaluator(
func=func,
input_text="test input",
expected_output="test output"
)
assert evaluator.func == func
assert evaluator.input_text == "test input"
assert evaluator.expected_output == "test output"
def test_init_with_agent(self):
"""Test initialization with agent."""
agent = MagicMock()
evaluator = AccuracyEvaluator(
agent=agent,
input_text="test",
expected_output="expected"
)
assert evaluator.agent == agent
def test_init_requires_agent_or_func(self):
"""Test that either agent or func must be provided."""
with pytest.raises(ValueError, match="Either 'agent' or 'func' must be provided"):
AccuracyEvaluator(
input_text="test",
expected_output="expected"
)
def test_get_output_from_func(self):
"""Test getting output from function."""
func = lambda x: f"output for {x}"
evaluator = AccuracyEvaluator(
func=func,
input_text="test",
expected_output="expected"
)
output = evaluator._get_output()
assert output == "output for test"
def test_get_output_from_agent_chat(self):
"""Test getting output from agent with chat method."""
agent = MagicMock()
agent.chat.return_value = "agent response"
evaluator = AccuracyEvaluator(
agent=agent,
input_text="test",
expected_output="expected"
)
output = evaluator._get_output()
assert output == "agent response"
agent.chat.assert_called_once_with("test")
def test_get_output_from_agent_start(self):
"""Test getting output from agent with start method."""
agent = MagicMock(spec=['start'])
result = MagicMock()
result.raw = "agent result"
agent.start.return_value = result
evaluator = AccuracyEvaluator(
agent=agent,
input_text="test",
expected_output="expected"
)
output = evaluator._get_output()
assert output == "agent result"
def test_judge_output(self):
"""Test LLM judging of output."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 8\nREASONING: Good match"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "4"
evaluator = AccuracyEvaluator(
func=test_func,
input_text="What is 2+2?",
expected_output="4"
)
score = evaluator._judge_output("4")
assert score.score == 8.0
assert "Good match" in score.reasoning
def test_run_returns_accuracy_result(self):
"""Test that run returns AccuracyResult."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 9\nREASONING: Excellent"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "4"
evaluator = AccuracyEvaluator(
func=test_func,
input_text="What is 2+2?",
expected_output="4",
num_iterations=2
)
result = evaluator.run()
assert isinstance(result, AccuracyResult)
assert len(result.evaluations) == 2
def test_evaluate_output(self):
"""Test evaluate_output method for pre-generated outputs."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 10\nREASONING: Perfect"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "unused"
evaluator = AccuracyEvaluator(
func=test_func,
input_text="What is 2+2?",
expected_output="4"
)
result = evaluator.evaluate_output("4")
assert isinstance(result, AccuracyResult)
assert len(result.evaluations) == 1
assert result.evaluations[0].score == 10.0
class TestPerformanceEvaluator:
"""Tests for PerformanceEvaluator class."""
def test_init_with_func(self):
"""Test initialization with function."""
func = lambda: time.sleep(0.01)
evaluator = PerformanceEvaluator(
func=func,
num_iterations=5,
warmup_runs=2
)
assert evaluator.func == func
assert evaluator.num_iterations == 5
assert evaluator.warmup_runs == 2
def test_init_with_agent(self):
"""Test initialization with agent."""
agent = MagicMock()
evaluator = PerformanceEvaluator(
agent=agent,
input_text="test",
num_iterations=3
)
assert evaluator.agent == agent
assert evaluator.input_text == "test"
def test_init_requires_func_or_agent(self):
"""Test that either func or agent must be provided."""
with pytest.raises(ValueError, match="Either 'func' or 'agent' must be provided"):
PerformanceEvaluator()
def test_run_measures_time(self):
"""Test that run measures execution time."""
call_count = 0
def slow_func():
nonlocal call_count
call_count += 1
time.sleep(0.01)
evaluator = PerformanceEvaluator(
func=slow_func,
num_iterations=3,
warmup_runs=1,
track_memory=False
)
result = evaluator.run()
assert isinstance(result, PerformanceResult)
assert len(result.metrics) == 3
assert all(m.run_time >= 0.01 for m in result.metrics)
assert call_count == 4 # 1 warmup + 3 iterations
def test_run_with_agent(self):
"""Test run with agent."""
agent = MagicMock()
agent.chat.return_value = "response"
evaluator = PerformanceEvaluator(
agent=agent,
input_text="test",
num_iterations=2,
warmup_runs=1,
track_memory=False
)
result = evaluator.run()
assert isinstance(result, PerformanceResult)
assert len(result.metrics) == 2
assert agent.chat.call_count == 3 # 1 warmup + 2 iterations
class TestReliabilityEvaluator:
"""Tests for ReliabilityEvaluator class."""
def test_init(self):
"""Test initialization."""
agent = MagicMock()
evaluator = ReliabilityEvaluator(
agent=agent,
input_text="test",
expected_tools=["search", "calculate"]
)
assert evaluator.agent == agent
assert evaluator.expected_tools == {"search", "calculate"}
def test_init_requires_agent(self):
"""Test that agent must be provided."""
with pytest.raises(ValueError, match="'agent' must be provided"):
ReliabilityEvaluator(
input_text="test",
expected_tools=["search"]
)
def test_evaluate_tool_calls(self):
"""Test evaluate_tool_calls method."""
agent = MagicMock()
evaluator = ReliabilityEvaluator(
agent=agent,
input_text="test",
expected_tools=["search", "calculate"],
forbidden_tools=["delete"]
)
result = evaluator.evaluate_tool_calls(["search", "calculate"])
assert isinstance(result, ReliabilityResult)
assert result.passed is True
assert len(result.passed_calls) == 3 # 2 expected + 1 forbidden not called
def test_evaluate_tool_calls_with_failures(self):
"""Test evaluate_tool_calls with missing tools."""
agent = MagicMock()
evaluator = ReliabilityEvaluator(
agent=agent,
input_text="test",
expected_tools=["search", "calculate"]
)
result = evaluator.evaluate_tool_calls(["search"]) # missing calculate
assert result.passed is False
assert len(result.failed_calls) == 1
def test_forbidden_tools(self):
"""Test forbidden tools detection."""
agent = MagicMock()
evaluator = ReliabilityEvaluator(
agent=agent,
input_text="test",
expected_tools=[],
forbidden_tools=["delete", "drop"]
)
result = evaluator.evaluate_tool_calls(["delete"]) # called forbidden tool
assert result.passed is False
assert len(result.failed_calls) == 1
class TestCriteriaEvaluator:
"""Tests for CriteriaEvaluator class."""
def test_init(self):
"""Test initialization."""
evaluator = CriteriaEvaluator(
criteria="Response is helpful",
func=lambda x: "test",
input_text="test"
)
assert evaluator.criteria == "Response is helpful"
assert evaluator.scoring_type == "numeric"
assert evaluator.threshold == 7.0
def test_init_binary_scoring(self):
"""Test initialization with binary scoring."""
evaluator = CriteriaEvaluator(
criteria="Response is helpful",
func=lambda x: "test",
scoring_type="binary"
)
assert evaluator.scoring_type == "binary"
def test_judge_output_numeric(self):
"""Test numeric scoring."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 8\nREASONING: Good"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "test"
evaluator = CriteriaEvaluator(
criteria="Be helpful",
func=test_func,
scoring_type="numeric",
threshold=7.0
)
score = evaluator._judge_output("test output")
assert score.score == 8.0
assert score.passed is True
def test_judge_output_binary(self):
"""Test binary scoring."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "RESULT: PASS\nREASONING: Meets criteria"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "test"
evaluator = CriteriaEvaluator(
criteria="Be helpful",
func=test_func,
scoring_type="binary"
)
score = evaluator._judge_output("test output")
assert score.score == 10.0
assert score.passed is True
def test_on_fail_callback(self):
"""Test on_fail callback is called."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 3\nREASONING: Poor"
mock_client.chat.completions.create.return_value = mock_response
on_fail_called = []
def on_fail(score):
on_fail_called.append(score)
def test_func(x):
return "test"
evaluator = CriteriaEvaluator(
criteria="Be helpful",
func=test_func,
threshold=7.0,
on_fail=on_fail
)
evaluator.run()
assert len(on_fail_called) == 1
assert on_fail_called[0].score == 3.0
def test_evaluate_output_criteria(self):
"""Test evaluate_output method."""
with patch('openai.OpenAI') as mock_openai_class:
mock_client = MagicMock()
mock_openai_class.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 9\nREASONING: Great"
mock_client.chat.completions.create.return_value = mock_response
def test_func(x):
return "unused"
evaluator = CriteriaEvaluator(
criteria="Be helpful",
func=test_func
)
result = evaluator.evaluate_output("pre-generated output")
assert isinstance(result, CriteriaResult)
assert len(result.evaluations) == 1
@@ -0,0 +1,359 @@
"""Unit tests for evaluation result dataclasses."""
import pytest
import json
from datetime import datetime
from praisonaiagents.eval.results import (
EvaluationScore,
AccuracyResult,
PerformanceMetrics,
PerformanceResult,
ToolCallResult,
ReliabilityResult,
CriteriaScore,
CriteriaResult,
)
class TestEvaluationScore:
"""Tests for EvaluationScore dataclass."""
def test_creation(self):
"""Test basic creation of EvaluationScore."""
score = EvaluationScore(
score=8.5,
reasoning="Good response",
input_text="What is 2+2?",
output_text="4",
expected_output="4"
)
assert score.score == 8.5
assert score.reasoning == "Good response"
assert score.input_text == "What is 2+2?"
assert score.output_text == "4"
assert score.expected_output == "4"
def test_timestamp_auto_generated(self):
"""Test that timestamp is auto-generated."""
score = EvaluationScore(
score=8.0,
reasoning="Test",
input_text="test",
output_text="test"
)
assert score.timestamp is not None
datetime.fromisoformat(score.timestamp)
def test_metadata_default(self):
"""Test that metadata defaults to empty dict."""
score = EvaluationScore(
score=8.0,
reasoning="Test",
input_text="test",
output_text="test"
)
assert score.metadata == {}
class TestAccuracyResult:
"""Tests for AccuracyResult dataclass."""
def test_empty_result(self):
"""Test empty AccuracyResult."""
result = AccuracyResult()
assert result.evaluations == []
assert result.avg_score == 0.0
assert result.min_score == 0.0
assert result.max_score == 0.0
assert result.std_dev == 0.0
assert result.passed is False
def test_single_evaluation(self):
"""Test AccuracyResult with single evaluation."""
result = AccuracyResult()
result.evaluations.append(EvaluationScore(
score=8.0,
reasoning="Good",
input_text="test",
output_text="test"
))
assert result.avg_score == 8.0
assert result.min_score == 8.0
assert result.max_score == 8.0
assert result.std_dev == 0.0
assert result.passed is True
def test_multiple_evaluations(self):
"""Test AccuracyResult with multiple evaluations."""
result = AccuracyResult()
for score in [6.0, 8.0, 10.0]:
result.evaluations.append(EvaluationScore(
score=score,
reasoning="Test",
input_text="test",
output_text="test"
))
assert result.avg_score == 8.0
assert result.min_score == 6.0
assert result.max_score == 10.0
assert result.passed is True
def test_passed_threshold(self):
"""Test passed property with different scores."""
result = AccuracyResult()
result.evaluations.append(EvaluationScore(
score=6.0,
reasoning="Below threshold",
input_text="test",
output_text="test"
))
assert result.passed is False
result.evaluations.append(EvaluationScore(
score=8.0,
reasoning="Above threshold",
input_text="test",
output_text="test"
))
assert result.passed is True
def test_to_dict(self):
"""Test to_dict method."""
result = AccuracyResult(eval_id="test123", name="test_eval")
result.evaluations.append(EvaluationScore(
score=8.0,
reasoning="Good",
input_text="test",
output_text="test"
))
d = result.to_dict()
assert d["eval_id"] == "test123"
assert d["name"] == "test_eval"
assert d["avg_score"] == 8.0
assert d["num_evaluations"] == 1
def test_to_json(self):
"""Test to_json method."""
result = AccuracyResult(eval_id="test123")
result.evaluations.append(EvaluationScore(
score=8.0,
reasoning="Good",
input_text="test",
output_text="test"
))
json_str = result.to_json()
data = json.loads(json_str)
assert data["eval_id"] == "test123"
class TestPerformanceMetrics:
"""Tests for PerformanceMetrics dataclass."""
def test_creation(self):
"""Test basic creation."""
metrics = PerformanceMetrics(
run_time=0.5,
memory_usage=10.5,
iteration=1
)
assert metrics.run_time == 0.5
assert metrics.memory_usage == 10.5
assert metrics.iteration == 1
class TestPerformanceResult:
"""Tests for PerformanceResult dataclass."""
def test_empty_result(self):
"""Test empty PerformanceResult."""
result = PerformanceResult()
assert result.metrics == []
assert result.avg_run_time == 0.0
assert result.min_run_time == 0.0
assert result.max_run_time == 0.0
assert result.avg_memory == 0.0
def test_with_metrics(self):
"""Test PerformanceResult with metrics."""
result = PerformanceResult()
for i, (time, mem) in enumerate([(0.1, 5.0), (0.2, 10.0), (0.3, 15.0)]):
result.metrics.append(PerformanceMetrics(
run_time=time,
memory_usage=mem,
iteration=i + 1
))
assert result.avg_run_time == 0.2
assert result.min_run_time == 0.1
assert result.max_run_time == 0.3
assert result.avg_memory == 10.0
assert result.max_memory == 15.0
def test_median_and_p95(self):
"""Test median and p95 calculations."""
result = PerformanceResult()
for i in range(100):
result.metrics.append(PerformanceMetrics(
run_time=float(i + 1) / 100,
memory_usage=1.0,
iteration=i + 1
))
assert 0.49 <= result.median_run_time <= 0.51
assert result.p95_run_time >= 0.95
def test_to_dict(self):
"""Test to_dict method."""
result = PerformanceResult(warmup_runs=5, eval_id="perf123")
result.metrics.append(PerformanceMetrics(
run_time=0.5,
memory_usage=10.0,
iteration=1
))
d = result.to_dict()
assert d["warmup_runs"] == 5
assert d["eval_id"] == "perf123"
assert d["num_iterations"] == 1
class TestToolCallResult:
"""Tests for ToolCallResult dataclass."""
def test_creation(self):
"""Test basic creation."""
tc = ToolCallResult(
tool_name="search",
expected=True,
actual=True,
passed=True
)
assert tc.tool_name == "search"
assert tc.expected is True
assert tc.actual is True
assert tc.passed is True
class TestReliabilityResult:
"""Tests for ReliabilityResult dataclass."""
def test_empty_result(self):
"""Test empty ReliabilityResult."""
result = ReliabilityResult()
assert result.tool_results == []
assert result.pass_rate == 0.0
assert result.status == "PASSED"
assert result.passed is True
def test_with_tool_calls(self):
"""Test ReliabilityResult with tool calls."""
result = ReliabilityResult()
result.tool_results.append(ToolCallResult(
tool_name="search",
expected=True,
actual=True,
passed=True
))
result.tool_results.append(ToolCallResult(
tool_name="calculate",
expected=True,
actual=False,
passed=False
))
assert len(result.passed_calls) == 1
assert len(result.failed_calls) == 1
assert result.pass_rate == 0.5
assert result.status == "FAILED"
assert result.passed is False
def test_all_passed(self):
"""Test when all tool calls pass."""
result = ReliabilityResult()
for name in ["tool1", "tool2", "tool3"]:
result.tool_results.append(ToolCallResult(
tool_name=name,
expected=True,
actual=True,
passed=True
))
assert result.pass_rate == 1.0
assert result.passed is True
class TestCriteriaScore:
"""Tests for CriteriaScore dataclass."""
def test_creation(self):
"""Test basic creation."""
score = CriteriaScore(
score=8.0,
passed=True,
reasoning="Meets criteria",
output_text="test output",
criteria="Be helpful"
)
assert score.score == 8.0
assert score.passed is True
assert score.reasoning == "Meets criteria"
class TestCriteriaResult:
"""Tests for CriteriaResult dataclass."""
def test_empty_result(self):
"""Test empty CriteriaResult."""
result = CriteriaResult(criteria="Be helpful")
assert result.evaluations == []
assert result.avg_score == 0.0
assert result.pass_rate == 0.0
def test_numeric_scoring(self):
"""Test numeric scoring mode."""
result = CriteriaResult(
criteria="Be helpful",
scoring_type="numeric",
threshold=7.0
)
result.evaluations.append(CriteriaScore(
score=8.0,
passed=True,
reasoning="Good",
output_text="test",
criteria="Be helpful"
))
assert result.avg_score == 8.0
assert result.passed is True
def test_binary_scoring(self):
"""Test binary scoring mode."""
result = CriteriaResult(
criteria="Be helpful",
scoring_type="binary"
)
result.evaluations.append(CriteriaScore(
score=10.0,
passed=True,
reasoning="Pass",
output_text="test",
criteria="Be helpful"
))
result.evaluations.append(CriteriaScore(
score=0.0,
passed=False,
reasoning="Fail",
output_text="test",
criteria="Be helpful"
))
assert result.pass_rate == 0.5
assert result.passed is True
def test_to_dict(self):
"""Test to_dict method."""
result = CriteriaResult(
criteria="Be helpful",
scoring_type="numeric",
threshold=7.0,
eval_id="crit123"
)
d = result.to_dict()
assert d["criteria"] == "Be helpful"
assert d["scoring_type"] == "numeric"
assert d["threshold"] == 7.0
@@ -0,0 +1,222 @@
"""Unit tests for evaluation utility functions."""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
from praisonaiagents.eval.utils import (
save_result_to_file,
load_result_from_file,
format_score,
format_percentage,
format_duration,
format_memory,
calculate_pass_rate,
)
class TestSaveResultToFile:
"""Tests for save_result_to_file function."""
def test_save_with_to_dict(self):
"""Test saving result with to_dict method."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0, "passed": True}
success = save_result_to_file(result, path)
assert success is True
assert Path(path).exists()
with open(path) as f:
data = json.load(f)
assert data["score"] == 8.0
def test_save_with_dict_attr(self):
"""Test saving result with __dict__ attribute."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
class SimpleResult:
def __init__(self):
self.score = 9.0
self.name = "test"
result = SimpleResult()
success = save_result_to_file(result, path)
assert success is True
with open(path) as f:
data = json.load(f)
assert data["score"] == 9.0
def test_save_with_placeholders(self):
"""Test saving with path placeholders."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/{{name}}_{{eval_id}}.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
success = save_result_to_file(
result, path,
eval_id="abc123",
name="test_eval"
)
assert success is True
expected_path = f"{tmpdir}/test_eval_abc123.json"
assert Path(expected_path).exists()
def test_save_creates_directories(self):
"""Test that save creates parent directories."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/nested/dir/result.json"
result = MagicMock()
result.to_dict.return_value = {"score": 8.0}
success = save_result_to_file(result, path)
assert success is True
assert Path(path).exists()
def test_save_failure_returns_false(self):
"""Test that save failure returns False."""
result = MagicMock()
result.to_dict.side_effect = Exception("Test error")
success = save_result_to_file(result, "/invalid/path/result.json")
assert success is False
class TestLoadResultFromFile:
"""Tests for load_result_from_file function."""
def test_load_existing_file(self):
"""Test loading existing result file."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/result.json"
data = {"score": 8.0, "passed": True}
with open(path, 'w') as f:
json.dump(data, f)
loaded = load_result_from_file(path)
assert loaded == data
def test_load_nonexistent_file(self):
"""Test loading nonexistent file returns None."""
result = load_result_from_file("/nonexistent/path/result.json")
assert result is None
def test_load_invalid_json(self):
"""Test loading invalid JSON returns None."""
with tempfile.TemporaryDirectory() as tmpdir:
path = f"{tmpdir}/invalid.json"
with open(path, 'w') as f:
f.write("not valid json")
result = load_result_from_file(path)
assert result is None
class TestFormatScore:
"""Tests for format_score function."""
def test_format_default_max(self):
"""Test formatting with default max score."""
assert format_score(8.5) == "8.5/10"
def test_format_custom_max(self):
"""Test formatting with custom max score."""
assert format_score(85.0, 100.0) == "85.0/100"
def test_format_zero(self):
"""Test formatting zero score."""
assert format_score(0.0) == "0.0/10"
class TestFormatPercentage:
"""Tests for format_percentage function."""
def test_format_full(self):
"""Test formatting 100%."""
assert format_percentage(1.0) == "100.0%"
def test_format_half(self):
"""Test formatting 50%."""
assert format_percentage(0.5) == "50.0%"
def test_format_zero(self):
"""Test formatting 0%."""
assert format_percentage(0.0) == "0.0%"
def test_format_decimal(self):
"""Test formatting decimal percentage."""
assert format_percentage(0.333) == "33.3%"
class TestFormatDuration:
"""Tests for format_duration function."""
def test_format_microseconds(self):
"""Test formatting microseconds."""
result = format_duration(0.0001)
assert "μs" in result
def test_format_milliseconds(self):
"""Test formatting milliseconds."""
result = format_duration(0.5)
assert "ms" in result
def test_format_seconds(self):
"""Test formatting seconds."""
result = format_duration(5.5)
assert "s" in result
assert "m" not in result
def test_format_minutes(self):
"""Test formatting minutes."""
result = format_duration(90.0)
assert "m" in result
class TestFormatMemory:
"""Tests for format_memory function."""
def test_format_kilobytes(self):
"""Test formatting kilobytes."""
result = format_memory(0.5)
assert "KB" in result
def test_format_megabytes(self):
"""Test formatting megabytes."""
result = format_memory(100.0)
assert "MB" in result
def test_format_gigabytes(self):
"""Test formatting gigabytes."""
result = format_memory(2048.0)
assert "GB" in result
class TestCalculatePassRate:
"""Tests for calculate_pass_rate function."""
def test_all_passed(self):
"""Test 100% pass rate."""
assert calculate_pass_rate(10, 10) == 1.0
def test_none_passed(self):
"""Test 0% pass rate."""
assert calculate_pass_rate(0, 10) == 0.0
def test_half_passed(self):
"""Test 50% pass rate."""
assert calculate_pass_rate(5, 10) == 0.5
def test_zero_total(self):
"""Test with zero total."""
assert calculate_pass_rate(0, 0) == 0.0
@@ -0,0 +1 @@
"""Unit tests for hooks module."""
@@ -0,0 +1,717 @@
"""
Unit tests for the Hooks module.
Tests cover:
- HookRegistry registration and lookup
- HookRunner execution (function and command hooks)
- Event types and inputs
- Decision handling (allow, deny, block)
"""
import pytest
import asyncio
import json
import os
import tempfile
from datetime import datetime
from unittest.mock import MagicMock, AsyncMock, patch
# Import hook types
from praisonaiagents.hooks.types import (
HookEvent, HookResult, HookInput, HookOutput,
HookDefinition, CommandHook, FunctionHook, HookExecutionResult
)
from praisonaiagents.hooks.events import (
BeforeToolInput, AfterToolInput, BeforeAgentInput,
AfterAgentInput, SessionStartInput, SessionEndInput
)
from praisonaiagents.hooks.registry import HookRegistry, get_default_registry
from praisonaiagents.hooks.runner import HookRunner
# =============================================================================
# HookResult Tests
# =============================================================================
class TestHookResult:
"""Tests for HookResult class."""
def test_allow_result(self):
"""Test creating an allow result."""
result = HookResult.allow("Allowed by policy")
assert result.decision == "allow"
assert result.reason == "Allowed by policy"
assert result.is_allowed()
assert not result.is_denied()
def test_deny_result(self):
"""Test creating a deny result."""
result = HookResult.deny("Blocked by policy")
assert result.decision == "deny"
assert result.reason == "Blocked by policy"
assert not result.is_allowed()
assert result.is_denied()
def test_block_result(self):
"""Test creating a block result."""
result = HookResult.block("Security violation")
assert result.decision == "block"
assert result.reason == "Security violation"
assert result.is_denied()
def test_ask_result(self):
"""Test creating an ask result."""
result = HookResult.ask("Requires confirmation")
assert result.decision == "ask"
assert result.reason == "Requires confirmation"
def test_default_result(self):
"""Test default result is allow."""
result = HookResult()
assert result.decision == "allow"
assert result.is_allowed()
# =============================================================================
# HookDefinition Tests
# =============================================================================
class TestHookDefinition:
"""Tests for HookDefinition class."""
def test_definition_creation(self):
"""Test creating a hook definition."""
hook = HookDefinition(
event=HookEvent.BEFORE_TOOL,
name="test_hook",
matcher="write_*"
)
assert hook.event == HookEvent.BEFORE_TOOL
assert hook.name == "test_hook"
assert hook.matcher == "write_*"
assert hook.enabled
def test_matcher_regex(self):
"""Test regex matching."""
hook = HookDefinition(matcher="write_.*")
assert hook.matches("write_file")
assert hook.matches("write_data")
assert not hook.matches("read_file")
def test_matcher_none(self):
"""Test that None matcher matches everything."""
hook = HookDefinition(matcher=None)
assert hook.matches("anything")
assert hook.matches("write_file")
def test_command_hook(self):
"""Test CommandHook creation."""
hook = CommandHook(
event=HookEvent.BEFORE_TOOL,
command="python validator.py",
env={"API_KEY": "test"}
)
assert hook.command == "python validator.py"
assert hook.env == {"API_KEY": "test"}
assert hook.shell
def test_function_hook(self):
"""Test FunctionHook creation."""
def my_hook(data):
return HookResult.allow()
hook = FunctionHook(
event=HookEvent.BEFORE_TOOL,
func=my_hook
)
assert hook.func == my_hook
assert hook.name == "my_hook"
# =============================================================================
# HookRegistry Tests
# =============================================================================
class TestHookRegistry:
"""Tests for HookRegistry class."""
def test_registry_creation(self):
"""Test creating a registry."""
registry = HookRegistry()
assert registry.enabled
assert len(registry) == 0
def test_register_function(self):
"""Test registering a function hook."""
registry = HookRegistry()
def my_hook(data):
return HookResult.allow()
hook_id = registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=my_hook,
name="my_hook"
)
assert hook_id is not None
assert len(registry) == 1
hooks = registry.get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) == 1
assert hooks[0].name == "my_hook"
def test_register_command(self):
"""Test registering a command hook."""
registry = HookRegistry()
hook_id = registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="python validator.py",
name="validator"
)
assert hook_id is not None
hooks = registry.get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) == 1
assert isinstance(hooks[0], CommandHook)
def test_decorator_registration(self):
"""Test decorator-based registration."""
registry = HookRegistry()
@registry.on(HookEvent.BEFORE_TOOL)
def validate_tool(data):
return HookResult.allow()
hooks = registry.get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) == 1
assert hooks[0].name == "validate_tool"
def test_unregister(self):
"""Test unregistering a hook."""
registry = HookRegistry()
hook_id = registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="echo test"
)
assert len(registry) == 1
assert registry.unregister(hook_id)
assert len(registry) == 0
def test_get_hooks_with_matcher(self):
"""Test getting hooks filtered by target."""
registry = HookRegistry()
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=lambda d: HookResult.allow(),
matcher="write_.*",
name="write_hook"
)
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=lambda d: HookResult.allow(),
matcher="read_.*",
name="read_hook"
)
write_hooks = registry.get_hooks(HookEvent.BEFORE_TOOL, "write_file")
assert len(write_hooks) == 1
assert write_hooks[0].name == "write_hook"
read_hooks = registry.get_hooks(HookEvent.BEFORE_TOOL, "read_file")
assert len(read_hooks) == 1
assert read_hooks[0].name == "read_hook"
def test_disable_enable_hook(self):
"""Test disabling and enabling hooks."""
registry = HookRegistry()
hook_id = registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="echo test"
)
assert len(registry.get_hooks(HookEvent.BEFORE_TOOL)) == 1
registry.disable_hook(hook_id)
assert len(registry.get_hooks(HookEvent.BEFORE_TOOL)) == 0
registry.enable_hook(hook_id)
assert len(registry.get_hooks(HookEvent.BEFORE_TOOL)) == 1
def test_clear_hooks(self):
"""Test clearing hooks."""
registry = HookRegistry()
registry.register_command(HookEvent.BEFORE_TOOL, "echo 1")
registry.register_command(HookEvent.AFTER_TOOL, "echo 2")
assert len(registry) == 2
registry.clear(HookEvent.BEFORE_TOOL)
assert len(registry) == 1
registry.clear()
assert len(registry) == 0
def test_list_hooks(self):
"""Test listing all hooks."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="echo test",
name="test_hook"
)
hooks_list = registry.list_hooks()
assert "before_tool" in hooks_list
assert len(hooks_list["before_tool"]) == 1
assert hooks_list["before_tool"][0]["name"] == "test_hook"
# =============================================================================
# Event Input Tests
# =============================================================================
class TestEventInputs:
"""Tests for event-specific input classes."""
def test_before_tool_input(self):
"""Test BeforeToolInput."""
input_data = BeforeToolInput(
session_id="test-session",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="write_file",
tool_input={"path": "/tmp/test.txt", "content": "hello"}
)
assert input_data.tool_name == "write_file"
assert input_data.tool_input["path"] == "/tmp/test.txt"
data_dict = input_data.to_dict()
assert data_dict["tool_name"] == "write_file"
def test_after_tool_input(self):
"""Test AfterToolInput."""
input_data = AfterToolInput(
session_id="test-session",
cwd="/tmp",
event_name="after_tool",
timestamp="2024-01-01T00:00:00",
tool_name="write_file",
tool_output="File written successfully",
execution_time_ms=100.5
)
assert input_data.tool_output == "File written successfully"
assert input_data.execution_time_ms == 100.5
def test_before_agent_input(self):
"""Test BeforeAgentInput."""
input_data = BeforeAgentInput(
session_id="test-session",
cwd="/tmp",
event_name="before_agent",
timestamp="2024-01-01T00:00:00",
prompt="Write a file",
tools_available=["write_file", "read_file"]
)
assert input_data.prompt == "Write a file"
assert "write_file" in input_data.tools_available
def test_session_start_input(self):
"""Test SessionStartInput."""
input_data = SessionStartInput(
session_id="test-session",
cwd="/tmp",
event_name="session_start",
timestamp="2024-01-01T00:00:00",
source="startup"
)
assert input_data.source == "startup"
# =============================================================================
# HookRunner Tests
# =============================================================================
class TestHookRunner:
"""Tests for HookRunner class."""
def _create_sample_input(self):
"""Create sample input data."""
return BeforeToolInput(
session_id="test-session",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="write_file",
tool_input={"path": "/tmp/test.txt"}
)
@pytest.mark.asyncio
async def test_execute_function_hook_allow(self):
"""Test executing a function hook that allows."""
registry = HookRegistry()
def allow_hook(data):
return HookResult.allow("Allowed")
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=allow_hook
)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].success
assert results[0].output.is_allowed()
@pytest.mark.asyncio
async def test_execute_function_hook_deny(self):
"""Test executing a function hook that denies."""
registry = HookRegistry()
def deny_hook(data):
return HookResult.deny("Not allowed")
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=deny_hook
)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].success
assert results[0].output.is_denied()
assert HookRunner.is_blocked(results)
@pytest.mark.asyncio
async def test_execute_async_function_hook(self):
"""Test executing an async function hook."""
registry = HookRegistry()
async def async_hook(data):
await asyncio.sleep(0.01)
return HookResult.allow("Async allowed")
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=async_hook,
is_async=True
)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].success
@pytest.mark.asyncio
async def test_execute_multiple_hooks_parallel(self):
"""Test executing multiple hooks in parallel."""
registry = HookRegistry()
call_order = []
def hook1(data):
call_order.append(1)
return HookResult.allow()
def hook2(data):
call_order.append(2)
return HookResult.allow()
registry.register_function(HookEvent.BEFORE_TOOL, hook1, sequential=False)
registry.register_function(HookEvent.BEFORE_TOOL, hook2, sequential=False)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 2
assert all(r.success for r in results)
@pytest.mark.asyncio
async def test_execute_sequential_hooks(self):
"""Test executing hooks sequentially."""
registry = HookRegistry()
call_order = []
def hook1(data):
call_order.append(1)
return HookResult.allow()
def hook2(data):
call_order.append(2)
return HookResult.allow()
registry.register_function(HookEvent.BEFORE_TOOL, hook1, sequential=True)
registry.register_function(HookEvent.BEFORE_TOOL, hook2, sequential=True)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 2
assert call_order == [1, 2]
@pytest.mark.asyncio
async def test_sequential_stops_on_deny(self):
"""Test that sequential execution stops on deny."""
registry = HookRegistry()
call_order = []
def hook1(data):
call_order.append(1)
return HookResult.deny("Blocked")
def hook2(data):
call_order.append(2)
return HookResult.allow()
registry.register_function(HookEvent.BEFORE_TOOL, hook1, sequential=True)
registry.register_function(HookEvent.BEFORE_TOOL, hook2, sequential=True)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1 # Only first hook executed
assert call_order == [1]
assert HookRunner.is_blocked(results)
@pytest.mark.asyncio
async def test_hook_timeout(self):
"""Test hook timeout handling."""
registry = HookRegistry()
async def slow_hook(data):
await asyncio.sleep(10) # Very slow
return HookResult.allow()
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=slow_hook,
is_async=True,
timeout=0.1 # 100ms timeout
)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert not results[0].success
assert "timed out" in results[0].error
@pytest.mark.asyncio
async def test_hook_exception_handling(self):
"""Test hook exception handling."""
registry = HookRegistry()
def failing_hook(data):
raise ValueError("Hook failed")
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=failing_hook
)
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert not results[0].success
assert "Hook failed" in results[0].error
@pytest.mark.asyncio
async def test_no_hooks_returns_empty(self):
"""Test that no hooks returns empty list."""
registry = HookRegistry()
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert results == []
@pytest.mark.asyncio
async def test_disabled_registry(self):
"""Test that disabled registry returns no hooks."""
registry = HookRegistry()
registry.register_function(
event=HookEvent.BEFORE_TOOL,
func=lambda d: HookResult.allow()
)
registry.enabled = False
runner = HookRunner(registry)
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert results == []
def test_is_blocked_helper(self):
"""Test is_blocked helper method."""
allow_result = HookExecutionResult(
hook_id="1",
hook_name="test",
event=HookEvent.BEFORE_TOOL,
success=True,
output=HookResult.allow()
)
deny_result = HookExecutionResult(
hook_id="2",
hook_name="test",
event=HookEvent.BEFORE_TOOL,
success=True,
output=HookResult.deny("Blocked")
)
assert not HookRunner.is_blocked([allow_result])
assert HookRunner.is_blocked([deny_result])
assert HookRunner.is_blocked([allow_result, deny_result])
def test_get_blocking_reason(self):
"""Test get_blocking_reason helper method."""
deny_result = HookExecutionResult(
hook_id="1",
hook_name="test",
event=HookEvent.BEFORE_TOOL,
success=True,
output=HookResult.deny("Security violation")
)
reason = HookRunner.get_blocking_reason([deny_result])
assert reason == "Security violation"
def test_aggregate_context(self):
"""Test aggregate_context helper method."""
result1 = HookExecutionResult(
hook_id="1",
hook_name="test1",
event=HookEvent.BEFORE_TOOL,
success=True,
output=HookResult(additional_context="Context 1")
)
result2 = HookExecutionResult(
hook_id="2",
hook_name="test2",
event=HookEvent.BEFORE_TOOL,
success=True,
output=HookResult(additional_context="Context 2")
)
context = HookRunner.aggregate_context([result1, result2])
assert "Context 1" in context
assert "Context 2" in context
# =============================================================================
# Command Hook Tests
# =============================================================================
class TestCommandHooks:
"""Tests for command hook execution."""
def _create_sample_input(self):
return BeforeToolInput(
session_id="test-session",
cwd="/tmp",
event_name="before_tool",
timestamp="2024-01-01T00:00:00",
tool_name="write_file",
tool_input={"path": "/tmp/test.txt"}
)
@pytest.mark.asyncio
async def test_command_hook_success(self):
"""Test successful command hook execution."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command='echo \'{"decision": "allow"}\'',
name="allow_hook"
)
runner = HookRunner(registry, cwd="/tmp")
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].success
assert results[0].output.decision == "allow"
@pytest.mark.asyncio
async def test_command_hook_deny(self):
"""Test command hook that denies."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command='echo \'{"decision": "deny", "reason": "Blocked"}\''
)
runner = HookRunner(registry, cwd="/tmp")
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].output.decision == "deny"
assert results[0].output.reason == "Blocked"
@pytest.mark.asyncio
async def test_command_hook_exit_code_2(self):
"""Test command hook with blocking exit code."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="exit 2"
)
runner = HookRunner(registry, cwd="/tmp")
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert results[0].exit_code == 2
assert results[0].output.decision == "deny"
@pytest.mark.asyncio
async def test_command_hook_timeout(self):
"""Test command hook timeout."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
command="sleep 10",
timeout=0.1
)
runner = HookRunner(registry, cwd="/tmp")
sample_input = self._create_sample_input()
results = await runner.execute(HookEvent.BEFORE_TOOL, sample_input)
assert len(results) == 1
assert not results[0].success
assert "timed out" in results[0].error
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for LSP module."""
@@ -0,0 +1,327 @@
"""
Unit tests for the LSP module.
Tests cover:
- LSP types (Position, Range, Diagnostic, etc.)
- LSPConfig creation and defaults
- LSPClient initialization
"""
import pytest
from praisonaiagents.lsp.types import (
Position, Range, Location, Diagnostic, DiagnosticSeverity,
CompletionItem, TextDocumentItem, TextDocumentIdentifier
)
from praisonaiagents.lsp.config import LSPConfig, DEFAULT_SERVERS
from praisonaiagents.lsp.client import LSPClient
# =============================================================================
# Position Tests
# =============================================================================
class TestPosition:
"""Tests for Position class."""
def test_position_creation(self):
"""Test creating a position."""
pos = Position(line=10, character=5)
assert pos.line == 10
assert pos.character == 5
def test_position_to_dict(self):
"""Test position serialization."""
pos = Position(line=10, character=5)
data = pos.to_dict()
assert data == {"line": 10, "character": 5}
def test_position_from_dict(self):
"""Test position deserialization."""
pos = Position.from_dict({"line": 10, "character": 5})
assert pos.line == 10
assert pos.character == 5
# =============================================================================
# Range Tests
# =============================================================================
class TestRange:
"""Tests for Range class."""
def test_range_creation(self):
"""Test creating a range."""
start = Position(line=0, character=0)
end = Position(line=10, character=20)
r = Range(start=start, end=end)
assert r.start.line == 0
assert r.end.line == 10
def test_range_to_dict(self):
"""Test range serialization."""
r = Range(
start=Position(0, 0),
end=Position(10, 20)
)
data = r.to_dict()
assert data["start"]["line"] == 0
assert data["end"]["line"] == 10
def test_range_from_dict(self):
"""Test range deserialization."""
r = Range.from_dict({
"start": {"line": 0, "character": 0},
"end": {"line": 10, "character": 20}
})
assert r.start.line == 0
assert r.end.character == 20
# =============================================================================
# Location Tests
# =============================================================================
class TestLocation:
"""Tests for Location class."""
def test_location_creation(self):
"""Test creating a location."""
loc = Location(
uri="file:///test.py",
range=Range(Position(0, 0), Position(10, 0))
)
assert loc.uri == "file:///test.py"
assert loc.range.start.line == 0
def test_location_to_dict(self):
"""Test location serialization."""
loc = Location(
uri="file:///test.py",
range=Range(Position(0, 0), Position(10, 0))
)
data = loc.to_dict()
assert data["uri"] == "file:///test.py"
assert "range" in data
# =============================================================================
# Diagnostic Tests
# =============================================================================
class TestDiagnostic:
"""Tests for Diagnostic class."""
def test_diagnostic_creation(self):
"""Test creating a diagnostic."""
diag = Diagnostic(
range=Range(Position(5, 0), Position(5, 10)),
message="Undefined variable 'x'",
severity=DiagnosticSeverity.ERROR
)
assert diag.message == "Undefined variable 'x'"
assert diag.severity == DiagnosticSeverity.ERROR
def test_diagnostic_default_severity(self):
"""Test default severity is ERROR."""
diag = Diagnostic(
range=Range(Position(0, 0), Position(0, 10)),
message="Test"
)
assert diag.severity == DiagnosticSeverity.ERROR
def test_diagnostic_to_dict(self):
"""Test diagnostic serialization."""
diag = Diagnostic(
range=Range(Position(5, 0), Position(5, 10)),
message="Test error",
severity=DiagnosticSeverity.WARNING,
code="W001",
source="test"
)
data = diag.to_dict()
assert data["message"] == "Test error"
assert data["severity"] == 2 # WARNING
assert data["code"] == "W001"
assert data["source"] == "test"
def test_diagnostic_from_dict(self):
"""Test diagnostic deserialization."""
diag = Diagnostic.from_dict({
"range": {
"start": {"line": 5, "character": 0},
"end": {"line": 5, "character": 10}
},
"message": "Test error",
"severity": 2,
"code": "W001"
})
assert diag.message == "Test error"
assert diag.severity == DiagnosticSeverity.WARNING
assert diag.code == "W001"
# =============================================================================
# CompletionItem Tests
# =============================================================================
class TestCompletionItem:
"""Tests for CompletionItem class."""
def test_completion_creation(self):
"""Test creating a completion item."""
item = CompletionItem(
label="print",
kind=3, # Function
detail="Built-in function"
)
assert item.label == "print"
assert item.kind == 3
assert item.detail == "Built-in function"
def test_completion_to_dict(self):
"""Test completion serialization."""
item = CompletionItem(
label="print",
kind=3,
detail="Built-in function",
insert_text="print($1)"
)
data = item.to_dict()
assert data["label"] == "print"
assert data["insertText"] == "print($1)"
def test_completion_from_dict(self):
"""Test completion deserialization."""
item = CompletionItem.from_dict({
"label": "print",
"kind": 3,
"detail": "Built-in function",
"insertText": "print($1)"
})
assert item.label == "print"
assert item.insert_text == "print($1)"
# =============================================================================
# TextDocument Tests
# =============================================================================
class TestTextDocumentTypes:
"""Tests for text document types."""
def test_text_document_item(self):
"""Test TextDocumentItem."""
doc = TextDocumentItem(
uri="file:///test.py",
language_id="python",
version=1,
text="print('hello')"
)
data = doc.to_dict()
assert data["uri"] == "file:///test.py"
assert data["languageId"] == "python"
assert data["version"] == 1
def test_text_document_identifier(self):
"""Test TextDocumentIdentifier."""
doc = TextDocumentIdentifier(uri="file:///test.py")
data = doc.to_dict()
assert data["uri"] == "file:///test.py"
# =============================================================================
# LSPConfig Tests
# =============================================================================
class TestLSPConfig:
"""Tests for LSPConfig class."""
def test_config_with_defaults(self):
"""Test config with default server."""
config = LSPConfig(language="python")
assert config.language == "python"
assert config.command == "pylsp"
assert config.args == []
def test_config_custom_command(self):
"""Test config with custom command."""
config = LSPConfig(
language="python",
command="pyright-langserver",
args=["--stdio"]
)
assert config.command == "pyright-langserver"
assert config.args == ["--stdio"]
def test_config_unknown_language(self):
"""Test config with unknown language raises error."""
with pytest.raises(ValueError):
LSPConfig(language="unknown_language")
def test_default_servers_exist(self):
"""Test that default servers are defined."""
assert "python" in DEFAULT_SERVERS
assert "javascript" in DEFAULT_SERVERS
assert "typescript" in DEFAULT_SERVERS
# =============================================================================
# LSPClient Tests
# =============================================================================
class TestLSPClient:
"""Tests for LSPClient class."""
def test_client_creation(self):
"""Test creating a client."""
client = LSPClient(language="python")
assert client.config.language == "python"
assert not client.is_running
def test_client_custom_command(self):
"""Test client with custom command."""
client = LSPClient(
language="python",
command="custom-server",
args=["--mode", "stdio"]
)
assert client.config.command == "custom-server"
assert client.config.args == ["--mode", "stdio"]
def test_client_root_uri(self):
"""Test client with root URI."""
client = LSPClient(
language="python",
root_uri="file:///workspace"
)
assert client.config.root_uri == "file:///workspace"
def test_client_not_running_initially(self):
"""Test client is not running initially."""
client = LSPClient(language="python")
assert not client.is_running
assert not client._initialized
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
"""Unit tests for policy module."""
@@ -0,0 +1,508 @@
"""
Unit tests for the Policy module.
Tests cover:
- PolicyResult creation
- PolicyRule matching and evaluation
- Policy evaluation
- PolicyEngine management and checks
"""
import pytest
from praisonaiagents.policy.types import PolicyAction, PolicyResult
from praisonaiagents.policy.policy import Policy, PolicyRule
from praisonaiagents.policy.config import PolicyConfig
from praisonaiagents.policy.engine import (
PolicyEngine, create_deny_tools_policy,
create_allow_tools_policy, create_read_only_policy
)
# =============================================================================
# PolicyResult Tests
# =============================================================================
class TestPolicyResult:
"""Tests for PolicyResult class."""
def test_result_creation(self):
"""Test creating a result."""
result = PolicyResult(
allowed=True,
action=PolicyAction.ALLOW,
reason="Test"
)
assert result.allowed
assert result.action == PolicyAction.ALLOW
def test_result_allow(self):
"""Test allow factory method."""
result = PolicyResult.allow("Allowed")
assert result.allowed
assert result.action == PolicyAction.ALLOW
assert result.reason == "Allowed"
def test_result_deny(self):
"""Test deny factory method."""
result = PolicyResult.deny("Not allowed", "test_policy")
assert not result.allowed
assert result.action == PolicyAction.DENY
assert result.reason == "Not allowed"
assert result.policy_name == "test_policy"
def test_result_ask(self):
"""Test ask factory method."""
result = PolicyResult.ask("Confirm action")
assert not result.allowed
assert result.action == PolicyAction.ASK
def test_result_to_dict(self):
"""Test result serialization."""
result = PolicyResult(
allowed=False,
action=PolicyAction.DENY,
policy_name="test",
reason="Test reason"
)
data = result.to_dict()
assert data["allowed"] is False
assert data["action"] == "deny"
assert data["policy_name"] == "test"
# =============================================================================
# PolicyRule Tests
# =============================================================================
class TestPolicyRule:
"""Tests for PolicyRule class."""
def test_rule_creation(self):
"""Test creating a rule."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="No delete"
)
assert rule.action == PolicyAction.DENY
assert rule.resource == "tool:delete_*"
def test_rule_exact_match(self):
"""Test exact resource matching."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_file"
)
assert rule.matches("tool:delete_file", {})
assert not rule.matches("tool:read_file", {})
def test_rule_wildcard_match(self):
"""Test wildcard resource matching."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*"
)
assert rule.matches("tool:delete_file", {})
assert rule.matches("tool:delete_directory", {})
assert not rule.matches("tool:read_file", {})
def test_rule_condition(self):
"""Test rule with condition."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:*",
condition=lambda ctx: ctx.get("dangerous", False)
)
assert rule.matches("tool:test", {"dangerous": True})
assert not rule.matches("tool:test", {"dangerous": False})
assert not rule.matches("tool:test", {})
def test_rule_evaluate_match(self):
"""Test rule evaluation when matching."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="No delete",
name="deny_delete"
)
result = rule.evaluate("tool:delete_file", {})
assert result is not None
assert not result.allowed
assert result.action == PolicyAction.DENY
assert result.reason == "No delete"
def test_rule_evaluate_no_match(self):
"""Test rule evaluation when not matching."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*"
)
result = rule.evaluate("tool:read_file", {})
assert result is None
def test_rule_to_dict(self):
"""Test rule serialization."""
rule = PolicyRule(
action=PolicyAction.DENY,
resource="tool:test",
reason="Test",
name="test_rule",
priority=10
)
data = rule.to_dict()
assert data["action"] == "deny"
assert data["resource"] == "tool:test"
assert data["priority"] == 10
# =============================================================================
# Policy Tests
# =============================================================================
class TestPolicy:
"""Tests for Policy class."""
def test_policy_creation(self):
"""Test creating a policy."""
policy = Policy(
name="test_policy",
description="Test policy"
)
assert policy.name == "test_policy"
assert policy.enabled
def test_policy_add_rule(self):
"""Test adding rules to a policy."""
policy = Policy(name="test")
policy.add_rule(PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*"
))
assert len(policy.rules) == 1
def test_policy_remove_rule(self):
"""Test removing rules from a policy."""
policy = Policy(name="test")
policy.add_rule(PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
name="deny_delete"
))
result = policy.remove_rule("deny_delete")
assert result
assert len(policy.rules) == 0
def test_policy_evaluate_match(self):
"""Test policy evaluation when rule matches."""
policy = Policy(
name="test",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="No delete"
)
]
)
result = policy.evaluate("tool:delete_file", {})
assert result is not None
assert not result.allowed
assert result.policy_name == "test"
def test_policy_evaluate_no_match(self):
"""Test policy evaluation when no rule matches."""
policy = Policy(
name="test",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*"
)
]
)
result = policy.evaluate("tool:read_file", {})
assert result is None
def test_policy_disabled(self):
"""Test disabled policy."""
policy = Policy(
name="test",
enabled=False,
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*"
)
]
)
result = policy.evaluate("tool:delete_file", {})
assert result is None
def test_policy_to_dict(self):
"""Test policy serialization."""
policy = Policy(
name="test",
description="Test policy",
rules=[
PolicyRule(action=PolicyAction.DENY, resource="tool:*")
]
)
data = policy.to_dict()
assert data["name"] == "test"
assert len(data["rules"]) == 1
def test_policy_from_dict(self):
"""Test policy deserialization."""
data = {
"name": "test",
"description": "Test",
"rules": [
{"action": "deny", "resource": "tool:delete_*"}
]
}
policy = Policy.from_dict(data)
assert policy.name == "test"
assert len(policy.rules) == 1
assert policy.rules[0].action == PolicyAction.DENY
# =============================================================================
# PolicyConfig Tests
# =============================================================================
class TestPolicyConfig:
"""Tests for PolicyConfig class."""
def test_config_defaults(self):
"""Test default configuration."""
config = PolicyConfig()
assert config.enabled
assert config.default_action == "allow"
assert not config.strict_mode
def test_config_custom(self):
"""Test custom configuration."""
config = PolicyConfig(
enabled=False,
strict_mode=True
)
assert not config.enabled
assert config.strict_mode
# =============================================================================
# PolicyEngine Tests
# =============================================================================
class TestPolicyEngine:
"""Tests for PolicyEngine class."""
@pytest.fixture
def engine(self):
"""Create a test engine."""
return PolicyEngine()
def test_engine_creation(self, engine):
"""Test creating an engine."""
assert engine.config.enabled
assert len(engine.policies) == 0
def test_engine_add_policy(self, engine):
"""Test adding a policy."""
policy = Policy(name="test")
engine.add_policy(policy)
assert len(engine.policies) == 1
assert engine.get_policy("test") is not None
def test_engine_remove_policy(self, engine):
"""Test removing a policy."""
engine.add_policy(Policy(name="test"))
result = engine.remove_policy("test")
assert result
assert len(engine.policies) == 0
def test_engine_check_allow(self, engine):
"""Test check that allows."""
engine.add_policy(Policy(
name="allow_read",
rules=[
PolicyRule(
action=PolicyAction.ALLOW,
resource="tool:read_*"
)
]
))
result = engine.check("tool:read_file", {})
assert result.allowed
def test_engine_check_deny(self, engine):
"""Test check that denies."""
engine.add_policy(Policy(
name="deny_delete",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="No delete"
)
]
))
result = engine.check("tool:delete_file", {})
assert not result.allowed
assert result.reason == "No delete"
def test_engine_check_no_match(self, engine):
"""Test check with no matching policy."""
result = engine.check("tool:anything", {})
assert result.allowed # Default is allow
def test_engine_strict_mode(self):
"""Test strict mode denies when no match."""
engine = PolicyEngine(PolicyConfig(strict_mode=True))
result = engine.check("tool:anything", {})
assert not result.allowed
def test_engine_disabled(self):
"""Test disabled engine allows everything."""
engine = PolicyEngine(PolicyConfig(enabled=False))
engine.add_policy(Policy(
name="deny_all",
rules=[PolicyRule(action=PolicyAction.DENY, resource="*")]
))
result = engine.check("tool:anything", {})
assert result.allowed
def test_engine_check_tool(self, engine):
"""Test check_tool helper."""
engine.add_policy(Policy(
name="deny_delete",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_file"
)
]
))
result = engine.check_tool("delete_file")
assert not result.allowed
def test_engine_check_file(self, engine):
"""Test check_file helper."""
engine.add_policy(Policy(
name="deny_write",
rules=[
PolicyRule(
action=PolicyAction.DENY,
resource="file:write"
)
]
))
result = engine.check_file("write", "/path/to/file")
assert not result.allowed
def test_engine_enable_disable_policy(self, engine):
"""Test enabling/disabling policies."""
engine.add_policy(Policy(name="test"))
engine.disable_policy("test")
assert not engine.get_policy("test").enabled
engine.enable_policy("test")
assert engine.get_policy("test").enabled
def test_engine_clear(self, engine):
"""Test clearing all policies."""
engine.add_policy(Policy(name="test1"))
engine.add_policy(Policy(name="test2"))
engine.clear()
assert len(engine.policies) == 0
def test_engine_to_dict(self, engine):
"""Test engine serialization."""
engine.add_policy(Policy(name="test"))
data = engine.to_dict()
assert "config" in data
assert "policies" in data
assert len(data["policies"]) == 1
# =============================================================================
# Convenience Function Tests
# =============================================================================
class TestConvenienceFunctions:
"""Tests for convenience policy creation functions."""
def test_create_deny_tools_policy(self):
"""Test creating deny tools policy."""
policy = create_deny_tools_policy(
["delete_*", "write_*"],
reason="Not allowed"
)
assert policy.name == "deny_tools"
assert len(policy.rules) == 2
def test_create_allow_tools_policy(self):
"""Test creating allow tools policy."""
policy = create_allow_tools_policy(["read_*", "list_*"])
assert policy.name == "allow_tools"
assert len(policy.rules) == 2
def test_create_read_only_policy(self):
"""Test creating read-only policy."""
policy = create_read_only_policy()
assert policy.name == "read_only"
assert len(policy.rules) >= 2
assert policy.priority == 100
if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more