Compare commits

...
39 Commits
Author SHA1 Message Date
Mervin Praison f991f37cfe Release v2.3.23 2025-12-17 00:41:48 +00:00
Mervin Praison a43b3ac72c Release v2.3.22 2025-12-17 00:25:14 +00:00
Mervin Praison 06f82249e1 Release v2.3.21 2025-12-17 00:05:40 +00:00
Mervin Praison d3aa78241a Release v2.3.20 2025-12-16 23:46:47 +00:00
Mervin Praison dfb77c39f9 Release v2.3.19 2025-12-16 23:26:53 +00:00
Mervin Praison 7e8f9b0574 Release v2.3.18 2025-12-16 23:18:36 +00:00
Mervin Praison 19bcdcd000 Release v2.3.16 2025-12-16 22:59:29 +00:00
Mervin Praison 8f5160f827 feat(workflows): Add branching, loops, callbacks, and per-step variables
- Add branching support with next_steps and branch_condition fields
- Add loop support with loop_over and loop_var for iterating over variables
- Add on_task_start and on_task_complete callbacks to PraisonAIAgents
- Add variables parameter for global variable substitution across tasks
- Add per-step agent creation from agent_config when no agent assigned
- Add task-level variable substitution in task descriptions
- Refactor workflow
2025-12-16 22:58:56 +00:00
Mervin Praison 3574a4d2c0 Release v2.3.15 2025-12-16 22:50:37 +00:00
Mervin Praison b552843fa9 docs: Update README workflow CLI with new features
- Add inline workflow examples (--workflow 'step1,step2')
- Add planning mode example (--planning)
- Add tools and save examples (--tools, --save)
- Update to use global flags instead of workflow-specific flags
2025-12-16 21:46:30 +00:00
Mervin Praison 0461a133df fix(workflow): Actually use planning mode when --planning is enabled
Before: --planning flag was stored but never used, agent.chat() was called
After: --planning flag creates agent with planning=True and calls agent.start()

The agent.start() method checks if planning is enabled and calls
_start_with_planning() which:
1. Creates a plan using PlanningAgent
2. Displays the generated plan
3. Executes each step with context passing
4. Compiles results into final output
2025-12-16 19:12:28 +00:00
Mervin Praison b33857de33 refactor(cli): Use global flags for workflow instead of workflow-specific flags
BREAKING CHANGE: Removed workflow-specific flags in favor of global flags

Before:
  praisonai workflow run 'name' --workflow-tools tavily --workflow-save --workflow-verbose

After:
  praisonai workflow run 'name' --tools tavily --save --verbose

This simplifies the CLI by reusing existing global flags:
- --llm (instead of --workflow-llm)
- --tools (instead of --workflow-tools)
- --planning (instead of --workflow-planning)
- --memory (instead of --workflow-memory)
- --save (instead of --workflow-save)
- --verbose (instead of --workflow-verbose)

Only --workflow-var remains workflow-specific for variable substitution.
2025-12-16 19:09:52 +00:00
Mervin Praison 433c643f6c feat(cli): Add inline workflow support without .md template
Usage: praisonai 'prompt' --workflow 'step1:action1,step2:action2'

Example:
  praisonai 'What is AI?' --workflow 'Research:Research the topic,Summarize:Write a summary' --workflow-save

Features:
- Parse inline workflow steps from --workflow flag
- Sequential execution with context passing
- All workflow options work: --workflow-tools, --workflow-save, etc.
- No .md template file required
2025-12-16 19:05:20 +00:00
Mervin Praison 214119a063 feat(cli): Add complete workflow CLI options
- Add --workflow-llm for LLM model selection
- Add --workflow-tools for tools (comma-separated)
- Add --workflow-planning for planning mode
- Add --workflow-memory for memory support
- Add --workflow-stream for streaming output
- Add --workflow-verbose for verbose output
- Add --workflow-save to save output to file
- Show final output after workflow completion
- Save all step outputs to markdown file
- Update help text with all options
2025-12-16 19:01:57 +00:00
Mervin Praison 989a9f6fc5 Release v2.3.14 2025-12-16 18:50:59 +00:00
Mervin Praison cf276a5640 feat(workflows): Add context passing, per-step agents, async execution
## New Features:
- Context passing between workflow steps with {{previous_output}} and {{step_name_output}}
- Per-step agent configuration with role, goal, backstory, and tools
- Per-step tools support
- Async execution with aexecute() method
- Planning mode at workflow level
- Enhanced markdown parser for agent/tools blocks

## WorkflowStep Enhancements:
- agent_config: Optional[Dict] - Per-step agent configuration
- tools: Optional[List] - Per-step tools
- context_from: Optional[List[str]] - Specific steps to include context from
- retain_full_context: bool - Include all previous outputs vs only last
- output_variable: Optional[str] - Custom variable name for step output

## Workflow Enhancements:
- default_llm: Optional[str] - Default LLM for all steps
- planning: bool - Enable planning mode
- planning_llm: Optional[str] - LLM for planning
- memory_config: Optional[Dict] - Memory configuration

## Execute Method Enhancements:
- default_agent parameter (executor now optional)
- default_llm, memory, planning, stream, verbose parameters

## Tests:
- 74 new tests covering all features
2025-12-16 18:49:43 +00:00
Mervin Praison b5134ffb2f Add --final-agent flag for post-processing output with specialized agent 2025-12-16 16:40:22 +00:00
Mervin Praison 5664036712 Release v2.3.13 2025-12-16 16:33:03 +00:00
Mervin Praison 6c2ca64cbe Remove unused --compile-results flag 2025-12-16 16:32:42 +00:00
Mervin Praison 08c742287c Fix: Pass max_tokens as dict config to Agent 2025-12-16 16:27:29 +00:00
Mervin Praison 4ee32b8eb2 Improve planning mode: compile all step results into comprehensive final report 2025-12-16 16:22:44 +00:00
Mervin Praison 35f07e5653 Add --max-tokens flag and improve planning mode output compilation 2025-12-16 16:22:37 +00:00
Mervin Praison 6481aec376 Add --tools support for all agents including planning mode 2025-12-16 16:14:39 +00:00
Mervin Praison 98c846ef78 Improve: Dynamic tool instructions from docstrings instead of hardcoded search 2025-12-16 16:01:30 +00:00
Mervin Praison 6e4a9faaf8 Fix: Instruct PromptExpanderAgent to use tools for context gathering 2025-12-16 15:59:46 +00:00
Mervin Praison 05d62db355 Reorganize README.md by moving Prompt Expansion, Web Search/Fetch, and MCP sections after CLI Features table, adding comprehensive programmatic usage examples and feature comparison tables for better documentation structure and clarity. 2025-12-16 15:52:36 +00:00
Mervin Praison 375c8c9826 Fix CLI docs links to use /docs/cli/ path 2025-12-16 15:41:42 +00:00
Mervin Praison c6977c8221 Add CLI Features table with docs links 2025-12-16 15:19:29 +00:00
Mervin Praison d407c83cf3 Add comprehensive CLI feature documentation to README.md, including Guardrail, Metrics, Image Processing, Telemetry, MCP, Fast Context, Knowledge, Session, Tools, Handoff, Auto Memory, Todo, Router, Flow Display, and Prompt Expansion with usage examples and command options for each feature. 2025-12-16 15:06:41 +00:00
Mervin Praison 214add68d9 Release v2.3.12 2025-12-16 15:03:23 +00:00
Mervin Praison 6d2cb9676d Update README.md to consolidate and enhance the Supported Providers section, adding new AI providers and their corresponding example links for improved documentation and user guidance. 2025-12-16 11:54:00 +00:00
Mervin Praison c519cb1379 Add supported providers section to README.md, listing various AI providers with example links for each, enhancing documentation and usability. 2025-12-16 11:52:19 +00:00
Mervin Praison 356dc4909a Update README.md to refine feature table, enhancing clarity by renaming 'Automated AI Agents Creation' to 'Single Agent' and reorganizing agent types for better categorization. 2025-12-16 11:25:42 +00:00
Mervin Praison dc278ad07b Enhance README.md with new agent types and advanced features, including Image Generation, Data Analyst, and Finance Agents, along with updates to existing feature descriptions and examples. 2025-12-16 11:22:23 +00:00
Mervin Praison 5cc53eb4bb Add advanced features section to README.md, detailing new capabilities including Deep Research Agents, Query Rewriter Agent, and various MCP enhancements. 2025-12-16 11:13:38 +00:00
Mervin Praison b72576d086 Refactor feature table in README.md for improved clarity and consistency, updating formatting and links for AI agent capabilities. 2025-12-16 11:11:04 +00:00
Mervin Praison 30314b1573 Update README.md to include detailed feature table with example code and documentation links for each AI agent capability. 2025-12-16 11:05:51 +00:00
Mervin Praison f721bd5e9a Add Fast Context feature to README highlights 2025-12-16 10:52:37 +00:00
Mervin Praison d00ea3e3c3 Release v2.3.11 2025-12-16 10:45:36 +00:00
109 changed files with 24766 additions and 176 deletions

No files matched your search

+888 -43
View File
@@ -32,42 +32,120 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
## Key Features
- 🤖 Automated AI Agents Creation
- 🔄 Self Reflection AI Agents
- 🧠 Reasoning AI Agents
- 👁️ Multi Modal AI Agents
- 🤝 Multi Agent Collaboration
- 🎭 AI Agent Workflow
- 📚 Add Custom Knowledge
- 🧠 Agents with Short and Long Term Memory
- 📄 Chat with PDF Agents
- 💻 Code Interpreter Agents
- 📚 RAG Agents
- 🤔 Async & Parallel Processing
- 🔄 Auto Agents
- 🔢 Math Agents
- 🎯 Structured Output Agents
- 🔗 LangChain Integrated Agents
- 📞 Callback Agents
- 🤏 Mini AI Agents
- 🛠️ 100+ Custom Tools
- 📄 YAML Configuration
- 💯 100+ LLM Support
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
| 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) |
| 🎭 AI Agent Workflow (Context Passing, Per-Step Agents) | [Example](examples/python/stateful/workflow-state-example.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 Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
| 📚 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) |
| 📋 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) |
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
| ⛓️ 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/cli/docs) |
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
| ⏸️ Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/cli/session) |
| 🔀 Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🔁 Workflow Loops | [Example](examples/python/workflows/workflow_loops.py) | [📖](https://docs.praison.ai/features/workflows) |
| 📞 Task Callbacks | [Example](examples/python/workflows/task_callbacks.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🛑 Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/features/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) |
| 🔄 Conditional Steps | [Example](examples/python/workflows/workflow_conditional.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🎭 Mixed Steps | [Example](examples/python/workflows/workflow_mixed_steps.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🔀 Agentic Routing | [Example](examples/python/workflows/workflow_routing.py) | [📖](https://docs.praison.ai/features/workflow-routing) |
| ⚡ Parallel Execution | [Example](examples/python/workflows/workflow_parallel.py) | [📖](https://docs.praison.ai/features/workflow-parallel) |
| 🔁 Loop over List | [Example](examples/python/workflows/workflow_loop_list.py) | [📖](https://docs.praison.ai/features/workflow-loop) |
| 📄 Loop over CSV | [Example](examples/python/workflows/workflow_loop_csv.py) | [📖](https://docs.praison.ai/features/workflow-loop) |
| 🔄 Evaluator-Optimizer | [Example](examples/python/workflows/workflow_repeat.py) | [📖](https://docs.praison.ai/features/workflow-repeat) |
## Supported Providers
| Provider | Example |
|----------|:-------:|
| OpenAI | [Example](examples/python/providers/openai/openai_gpt4_example.py) |
| Anthropic | [Example](examples/python/providers/anthropic/anthropic_claude_example.py) |
| Google Gemini | [Example](examples/python/providers/google/google_gemini_example.py) |
| Ollama | [Example](examples/python/providers/ollama/ollama-agents.py) |
| Groq | [Example](examples/python/providers/groq/kimi_with_groq_example.py) |
| DeepSeek | [Example](examples/python/providers/deepseek/deepseek_example.py) |
| xAI Grok | [Example](examples/python/providers/xai/xai_grok_example.py) |
| Mistral | [Example](examples/python/providers/mistral/mistral_example.py) |
| Cohere | [Example](examples/python/providers/cohere/cohere_example.py) |
| Perplexity | [Example](examples/python/providers/perplexity/perplexity_example.py) |
| Fireworks | [Example](examples/python/providers/fireworks/fireworks_example.py) |
| Together AI | [Example](examples/python/providers/together/together_ai_example.py) |
| OpenRouter | [Example](examples/python/providers/openrouter/openrouter_example.py) |
| HuggingFace | [Example](examples/python/providers/huggingface/huggingface_example.py) |
| Azure OpenAI | [Example](examples/python/providers/azure/azure_openai_example.py) |
| AWS Bedrock | [Example](examples/python/providers/aws/aws_bedrock_example.py) |
| Google Vertex | [Example](examples/python/providers/vertex/vertex_example.py) |
| Databricks | [Example](examples/python/providers/databricks/databricks_example.py) |
| Cloudflare | [Example](examples/python/providers/cloudflare/cloudflare_example.py) |
| AI21 | [Example](examples/python/providers/ai21/ai21_example.py) |
| Replicate | [Example](examples/python/providers/replicate/replicate_example.py) |
| SageMaker | [Example](examples/python/providers/sagemaker/sagemaker_example.py) |
| Moonshot | [Example](examples/python/providers/moonshot/moonshot_example.py) |
| vLLM | [Example](examples/python/providers/vllm/vllm_example.py) |
## Using Python Code
@@ -334,19 +412,172 @@ memories = auto.process_interaction(
### 8. Workflows
Create reusable multi-step workflows in `.praison/workflows/`:
Create reusable multi-step workflows with context passing and per-step agents:
```python
from praisonaiagents.memory import WorkflowManager
from praisonaiagents import Agent
from praisonaiagents.memory import WorkflowManager, Workflow, WorkflowStep
# Simple execution with default agent
agent = Agent(name="Assistant", llm="gpt-4o-mini")
manager = WorkflowManager()
# Execute a workflow
result = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
default_agent=agent,
variables={"environment": "production"}
)
# Advanced: Per-step agent configuration
workflow = Workflow(
name="research_pipeline",
default_llm="gpt-4o-mini",
steps=[
WorkflowStep(
name="research",
action="Research {{topic}}",
agent_config={"role": "Researcher", "goal": "Find information"},
tools=["tavily_search"]
),
WorkflowStep(
name="write",
action="Write report based on {{previous_output}}",
agent_config={"role": "Writer", "goal": "Write content"},
context_from=["research"] # Only include research output
)
]
)
# Async execution
import asyncio
result = asyncio.run(manager.aexecute("deploy", default_llm="gpt-4o-mini"))
```
**Key Features:**
- **Context Passing**: Use `{{previous_output}}` and `{{step_name_output}}` variables
- **Per-Step Agents**: Configure different agents with roles, goals, tools for each step
- **Async Execution**: Use `aexecute()` for async workflows
- **Planning Mode**: Enable at workflow level with `planning=True`
- **Branching**: Use `next_steps` and `branch_condition` for conditional routing
- **Loops**: Use `loop_over` and `loop_var` to iterate over data
### Choosing the Right Workflow System
| Use Case | Recommended |
|----------|-------------|
| Simple function pipelines | `Workflow` class ⭐ |
| Agent-only pipelines | `Workflow` class |
| CSV batch processing | `process="workflow"` |
| Complex task routing | `process="workflow"` |
| Markdown templates | `WorkflowManager` |
| Early stop / conditional | `Workflow` class |
### Simple Workflow (Recommended)
The easiest way to create workflows - just pass functions as steps:
```python
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Define simple handler functions
def validate(ctx: WorkflowContext) -> StepResult:
if not ctx.input:
return StepResult(output="No input", stop_workflow=True)
return StepResult(output=f"Valid: {ctx.input}")
def process(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"Processed: {ctx.previous_result}")
# Create and run workflow
workflow = Workflow(steps=[validate, process])
result = workflow.start("Hello World", verbose=True)
print(result["output"]) # "Processed: Valid: Hello World"
```
**Key Features:**
- **Just pass functions** - No complex configuration needed
- **Early stop** - Return `stop_workflow=True` to stop the workflow
- **Context passing** - Access `ctx.input`, `ctx.previous_result`, `ctx.variables`
- **Verbose mode** - See step-by-step progress
### Workflow Branching & Loops
```python
from praisonaiagents import WorkflowStep
# Branching step
decision_step = WorkflowStep(
name="decide",
action="Evaluate if task is complete",
next_steps=["success_step", "retry_step"],
branch_condition={"success": ["success_step"], "failure": ["retry_step"]}
)
# Loop step
loop_step = WorkflowStep(
name="process_items",
action="Process {{item}}",
loop_over="items", # Variable containing list
loop_var="item" # Current item variable name
)
```
### Workflow Patterns (route, parallel, loop, repeat)
```python
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Or use Pipeline (alias for Workflow)
from praisonaiagents import Pipeline
from praisonaiagents.workflows import route, parallel, loop, repeat
# 1. ROUTING - Decision-based branching
workflow = Workflow(steps=[
classify_request, # Returns "approve" or "reject"
route({
"approve": [approve_handler],
"reject": [reject_handler],
"default": [fallback_handler]
})
])
# 2. PARALLEL - Concurrent execution
workflow = Workflow(steps=[
parallel([research_market, research_competitors, research_customers]),
summarize_results # Gets all parallel outputs
])
# 3. LOOP - Iterate over list or CSV
workflow = Workflow(
steps=[loop(process_item, over="items")],
variables={"items": ["a", "b", "c"]}
)
# Or from CSV file:
workflow = Workflow(steps=[loop(process_row, from_csv="data.csv")])
# 4. REPEAT - Evaluator-Optimizer pattern
workflow = Workflow(steps=[
repeat(
generator,
until=lambda ctx: "done" in ctx.previous_result,
max_iterations=5
)
])
# 5. CALLBACKS - Monitor workflow execution
workflow = Workflow(
steps=[step1, step2],
on_workflow_start=lambda w, i: print(f"Starting: {i}"),
on_step_complete=lambda name, r: print(f"{name}: {r.output[:50]}"),
on_workflow_complete=lambda w, r: print(f"Done: {r['status']}")
)
# 6. GUARDRAILS - Validate and retry
def validate(result):
return ("error" not in result.output, "Fix the error")
workflow = Workflow(steps=[
WorkflowStep(name="gen", handler=generator, guardrail=validate, max_retries=3)
])
```
### 9. Hooks
@@ -483,19 +714,49 @@ praisonai "Task" --include-rules security,testing
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute a workflow with tools and save output
praisonai workflow run "Research Blog" --tools tavily --save
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Execute with planning mode (AI creates sub-steps for each workflow step)
praisonai workflow run "Research Blog" --planning --verbose
# Execute with reasoning mode (chain-of-thought)
praisonai workflow run "Analysis" --reasoning --verbose
# Execute with memory enabled
praisonai workflow run "Research" --memory
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
# Inline workflow (no template file needed)
praisonai "What is AI?" --workflow "Research,Summarize" --save
# Inline workflow with step actions
praisonai "GPT-5" --workflow "Research:Search for info,Write:Write blog" --tools tavily
# Workflow CLI help
praisonai workflow help
```
**Workflow CLI Options:**
| Flag | Description |
|------|-------------|
| `--workflow-var key=value` | Set workflow variable (can be repeated) |
| `--llm <model>` | LLM model (e.g., openai/gpt-4o-mini) |
| `--tools <tools>` | Tools (comma-separated, e.g., tavily) |
| `--planning` | Enable planning mode |
| `--reasoning` | Enable reasoning mode |
| `--memory` | Enable memory |
| `--verbose` | Enable verbose output |
| `--save` | Save output to file |
### Hooks CLI:
```bash
# List configured hooks
@@ -514,6 +775,465 @@ praisonai hooks init
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
### Guardrail CLI:
```bash
# Validate output with LLM guardrail
praisonai "Write code" --guardrail "Ensure code is secure and follows best practices"
# Combine with other flags
praisonai "Generate SQL query" --guardrail "No DROP or DELETE statements" --save
```
### Metrics CLI:
```bash
# Display token usage and cost metrics
praisonai "Analyze this data" --metrics
# Combine with other features
praisonai "Complex task" --metrics --planning
```
### Image Processing CLI:
```bash
# Process images with vision-based tasks
praisonai "Describe this image" --image path/to/image.png
# Analyze image content
praisonai "What objects are in this photo?" --image photo.jpg --llm openai/gpt-4o
```
### Telemetry CLI:
```bash
# Enable usage monitoring and analytics
praisonai "Task" --telemetry
# Combine with metrics for full observability
praisonai "Complex analysis" --telemetry --metrics
```
### MCP (Model Context Protocol) CLI:
```bash
# Use MCP server tools
praisonai "Search files" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
# MCP with environment variables
praisonai "Search web" --mcp "npx -y @modelcontextprotocol/server-brave-search" --mcp-env "BRAVE_API_KEY=your_key"
# Multiple MCP options
praisonai "Task" --mcp "npx server" --mcp-env "KEY1=value1,KEY2=value2"
```
### Fast Context CLI:
```bash
# Search codebase for relevant context
praisonai "Find authentication code" --fast-context ./src
# Add code context to any task
praisonai "Explain this function" --fast-context /path/to/project
```
### Knowledge CLI:
```bash
# Add documents to knowledge base
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
# Search knowledge base
praisonai knowledge search "API authentication"
# List indexed documents
praisonai knowledge list
# Clear knowledge base
praisonai knowledge clear
# Show knowledge base info
praisonai knowledge info
# Show all commands
praisonai knowledge help
```
### Session CLI:
```bash
# List all saved sessions
praisonai session list
# Show session details
praisonai session show my-project
# Resume a session (load into memory)
praisonai session resume my-project
# Delete a session
praisonai session delete my-project
# Auto-save session after each run
praisonai "Analyze this code" --auto-save my-project
# Load history from last N sessions into context
praisonai "Continue our discussion" --history 5
```
### Session Management (Python):
```python
from praisonaiagents import Agent
# Auto-save session after each run
agent = Agent(
name="Assistant",
memory=True,
auto_save="my-project"
)
# Load history from last 5 sessions
agent = Agent(
name="Assistant",
memory=True,
history_in_context=5
)
```
### Workflow Checkpoints:
```python
from praisonaiagents.memory.workflows import WorkflowManager
manager = WorkflowManager()
# Save checkpoint after each step
result = manager.execute("deploy", checkpoint="deploy-v1")
# Resume from checkpoint
result = manager.execute("deploy", resume="deploy-v1")
# List/delete checkpoints
manager.list_checkpoints()
manager.delete_checkpoint("deploy-v1")
```
### Tools CLI:
```bash
# List all available tools
praisonai tools list
# Get info about a specific tool
praisonai tools info internet_search
# Search for tools
praisonai tools search "web"
# Show all commands
praisonai tools help
```
### Handoff CLI:
```bash
# Enable agent-to-agent task delegation
praisonai "Research and write article" --handoff "researcher,writer,editor"
# Complex multi-agent workflow
praisonai "Analyze data and create report" --handoff "analyst,visualizer,writer"
```
### Auto Memory CLI:
```bash
# Enable automatic memory extraction
praisonai "Learn about user preferences" --auto-memory
# Combine with user isolation
praisonai "Remember my settings" --auto-memory --user-id user123
```
### Todo CLI:
```bash
# Generate todo list from task
praisonai "Plan the project" --todo
# Add a todo item
praisonai todo add "Implement feature X"
# List all todos
praisonai todo list
# Complete a todo
praisonai todo complete 1
# Delete a todo
praisonai todo delete 1
# Clear all todos
praisonai todo clear
# Show all commands
praisonai todo help
```
### Router CLI:
```bash
# Auto-select best model based on task complexity
praisonai "Simple question" --router
# Specify preferred provider
praisonai "Complex analysis" --router --router-provider anthropic
# Router automatically selects:
# - Simple tasks → gpt-4o-mini, claude-3-haiku
# - Complex tasks → gpt-4-turbo, claude-3-opus
```
### Flow Display CLI:
```bash
# Enable visual workflow tracking
praisonai agents.yaml --flow-display
# Combine with other features
praisonai "Multi-step task" --planning --flow-display
```
### Docs CLI:
```bash
# List all project docs
praisonai docs list
# Create a new doc
praisonai docs create project-overview "This project is a Python web app..."
# Show a specific doc
praisonai docs show project-overview
# Delete a doc
praisonai docs delete old-doc
# Show all commands
praisonai docs help
```
### MCP Config CLI:
```bash
# List all MCP configurations
praisonai mcp list
# Create a new MCP config
praisonai mcp create filesystem npx -y @modelcontextprotocol/server-filesystem .
# Show a specific config
praisonai mcp show filesystem
# Enable/disable a config
praisonai mcp enable filesystem
praisonai mcp disable filesystem
# Delete a config
praisonai mcp delete filesystem
# Show all commands
praisonai mcp help
```
### AI Commit CLI:
```bash
# Generate AI commit message for staged changes
praisonai commit
# Generate, commit, and push
praisonai commit --push
```
### @Mentions in Prompts:
```bash
# Include file content in prompt
praisonai "@file:src/main.py explain this code"
# Include project doc
praisonai "@doc:project-overview help me add a feature"
# Search the web
praisonai "@web:python best practices give me tips"
# Fetch URL content
praisonai "@url:https://docs.python.org summarize this"
# Combine multiple mentions
praisonai "@file:main.py @doc:coding-standards review this code"
```
## Prompt Expansion
Expand short prompts into detailed, actionable prompts:
### CLI Usage
```bash
# Expand a short prompt into detailed prompt
praisonai "write a movie script in 3 lines" --expand-prompt
# With verbose output
praisonai "blog about AI" --expand-prompt -v
# With tools for context gathering
praisonai "latest AI trends" --expand-prompt --expand-tools tools.py
# Combine with query rewrite
praisonai "AI news" --query-rewrite --expand-prompt
```
### Programmatic Usage
```python
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
# Basic usage
agent = PromptExpanderAgent()
result = agent.expand("write a movie script in 3 lines")
print(result.expanded_prompt)
# With specific strategy
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
# Available strategies: BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO
```
**Key Difference:**
- `--query-rewrite`: Optimizes queries for search/retrieval (RAG)
- `--expand-prompt`: Expands prompts for detailed task execution
## Web Search, Web Fetch & Prompt Caching
### CLI Usage
```bash
# Web Search - Get real-time information
praisonai "What are the latest AI news today?" --web-search --llm openai/gpt-4o-search-preview
# Web Fetch - Retrieve and analyze URL content (Anthropic only)
praisonai "Summarize https://docs.praison.ai" --web-fetch --llm anthropic/claude-sonnet-4-20250514
# Prompt Caching - Reduce costs for repeated prompts
praisonai "Analyze this document..." --prompt-caching --llm anthropic/claude-sonnet-4-20250514
```
### Programmatic Usage
```python
from praisonaiagents import Agent
# Web Search
agent = Agent(
instructions="You are a research assistant",
llm="openai/gpt-4o-search-preview",
web_search=True
)
# Web Fetch (Anthropic only)
agent = Agent(
instructions="You are a content analyzer",
llm="anthropic/claude-sonnet-4-20250514",
web_fetch=True
)
# Prompt Caching
agent = Agent(
instructions="You are an AI assistant..." * 50, # Long system prompt
llm="anthropic/claude-sonnet-4-20250514",
prompt_caching=True
)
```
**Supported Providers:**
| Feature | Providers |
|---------|----------|
| Web Search | OpenAI, Gemini, Anthropic, xAI, Perplexity |
| 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"}
)
)
```
### 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 |
## CLI Features
| Feature | Docs |
|---------|:----:|
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
## Using JavaScript Code
```bash
@@ -932,7 +1652,6 @@ praisonai agents.yaml
</a>
</div>
## Custom Tools
### Using `@tool` Decorator
@@ -1098,6 +1817,73 @@ agent = Agent(
| 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"}
)
)
```
### 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 |
## Development:
Below is used for development only.
@@ -1138,6 +1924,65 @@ cd src/praisonai && uv publish
- Submit a pull request via GitHub's web interface.
- Await feedback from project maintainers.
## Advanced Features
**Research & Intelligence:**
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 📝 **Prompt Expander Agent** (Expand short prompts into detailed instructions)
**Memory & Caching:**
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
**Planning & Workflows:**
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
- ⛓️ **Prompt Chaining** (Sequential prompt workflows with gates)
- 🔍 **Evaluator Optimiser** (Generate and optimize through iterative feedback)
- 👷 **Orchestrator Workers** (Distribute tasks among specialized workers)
- ⚡ **Parallelisation** (Execute tasks in parallel for improved performance)
- 🔁 **Repetitive Agents** (Handle repetitive tasks through automated loops)
- 🤖 **Autonomous Workflow** (Monitor, act, adapt based on environment feedback)
**Agent Types:**
- 🖼️ **Image Generation Agent** (Create images from text descriptions)
- 📷 **Image to Text Agent** (Extract text and descriptions from images)
- 🎬 **Video Agent** (Analyze and process video content)
- 📊 **Data Analyst Agent** (Analyze data and generate insights)
- 💰 **Finance Agent** (Financial analysis and recommendations)
- 🛒 **Shopping Agent** (Price comparison and shopping assistance)
- ⭐ **Recommendation Agent** (Personalized recommendations)
- 📖 **Wikipedia Agent** (Search and extract Wikipedia information)
- 💻 **Programming Agent** (Code development and analysis)
- 📝 **Markdown Agent** (Generate and format Markdown content)
- 🔀 **Router Agent** (Dynamic task routing with cost optimization)
**MCP Protocol:**
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
**Safety & Control:**
- 🤝 **Agent Handoffs** (Transfer context between specialized agents)
- 🛡️ **Guardrails** (Input/output validation and safety checks)
- ✅ **Human Approval** (Require human confirmation for critical actions)
- 💬 **Sessions Management** (Isolated conversation contexts)
- 🔄 **Stateful Agents** (Maintain state across interactions)
**Developer Tools:**
- ⚡ **Fast Context** (Rapid parallel code search - 10-20x faster than traditional methods)
- 📜 **Rules & Instructions** (Auto-discover CLAUDE.md, AGENTS.md, GEMINI.md)
- 🪝 **Hooks** (Pre/post operation hooks for custom logic)
- 📈 **Telemetry** (Track agent performance and usage)
- 📹 **Camera Integration** (Capture and analyze camera input)
## Other Features
- 🔄 Use CrewAI or AG2 (Formerly AutoGen) Framework
+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 \
flask \
"praisonai>=2.3.10" \
"praisonai>=2.3.23" \
"praisonai[api]" \
gunicorn \
markdown
+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.10" \
"praisonai>=2.3.23" \
"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.10" \
"praisonai>=2.3.23" \
"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.10" \
"praisonai>=2.3.23" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,89 @@
"""
Fast Context with Agent - Basic Example
This is the simplest way to use Fast Context with an Agent.
Just set fast_context=True and the agent will automatically
have access to rapid code search capabilities.
Fast Context provides:
- 10-20x faster code search than traditional methods
- Parallel execution (8 concurrent searches)
- Automatic caching for repeated queries
- Seamless integration with Agent workflows
"""
from praisonaiagents import Agent
def main():
print("=" * 60)
print("Agent with Fast Context - Basic Example")
print("=" * 60)
# Create an agent with Fast Context enabled
# This is the simplest configuration
agent = Agent(
name="CodeAssistant",
instructions="You are a helpful code assistant.",
fast_context=True, # Enable Fast Context
fast_context_path=".", # Search in current directory
verbose=False
)
print("\n✓ Agent created with Fast Context enabled")
print(f" - fast_context_enabled: {agent.fast_context_enabled}")
print(f" - fast_context_path: {agent.fast_context_path}")
print(f" - fast_context_model: {agent.fast_context_model}")
# Use delegate_to_fast_context to search code
print("\n" + "-" * 60)
print("Searching for 'class Agent' in the codebase...")
print("-" * 60)
context = agent.delegate_to_fast_context("class Agent")
if context:
print(f"\n✓ Found relevant code!")
print(f" Context length: {len(context)} characters")
print("\nPreview of found context:")
print("-" * 40)
# Show first 500 characters
preview = context[:500]
print(preview)
if len(context) > 500:
print("...")
else:
print("\n✗ No relevant code found")
# Another search example
print("\n" + "-" * 60)
print("Searching for 'def chat' in the codebase...")
print("-" * 60)
context = agent.delegate_to_fast_context("def chat")
if context:
print(f"\n✓ Found relevant code!")
print(f" Context length: {len(context)} characters")
else:
print("\n✗ No relevant code found")
# Access FastContext directly for more control
print("\n" + "-" * 60)
print("Direct FastContext access for advanced usage...")
print("-" * 60)
fc = agent.fast_context
if fc:
result = fc.search("import")
print(f"\n✓ Direct search for 'import':")
print(f" Files found: {result.total_files}")
print(f" Search time: {result.search_time_ms}ms")
print(f" From cache: {result.from_cache}")
print("\n" + "=" * 60)
print("Fast Context makes code search instant for your agents!")
print("=" * 60)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""
Example 1: Basic Fast Context Usage
This example demonstrates the basic usage of Fast Context for rapid code search.
Fast Context is 10-20x faster than traditional sequential search because it:
- Executes up to 8 tool calls in parallel
- Limits search to 4 turns maximum
- Uses a lightweight model optimized for search
"""
from praisonaiagents.context.fast import FastContext
import time
def main():
print("=" * 70)
print("Fast Context - Basic Usage Example")
print("=" * 70)
# Create FastContext instance pointing to the praisonai-agents codebase
fc = FastContext(
workspace_path="/Users/praison/praisonai-package/src/praisonai-agents"
)
# Example 1: Simple pattern search
print("\n1. Simple Pattern Search")
print("-" * 40)
start = time.perf_counter()
result = fc.search("def chat")
elapsed = (time.perf_counter() - start) * 1000
print(f" Query: 'def chat'")
print(f" Files found: {result.total_files}")
print(f" Search time: {result.search_time_ms}ms (total: {elapsed:.0f}ms)")
print(f" Turns used: {result.turns_used}")
print(f" Tool calls: {result.total_tool_calls}")
if result.files:
print("\n Top matches:")
for f in result.files[:3]:
print(f" - {f.path}")
for lr in f.line_ranges[:2]:
print(f" Lines {lr.start}-{lr.end}")
# Example 2: Search for class definitions
print("\n2. Search for Class Definitions")
print("-" * 40)
result = fc.search("class Agent")
print(f" Query: 'class Agent'")
print(f" Files found: {result.total_files}")
print(f" Search time: {result.search_time_ms}ms")
if result.files:
print("\n Files containing 'class Agent':")
for f in result.files[:5]:
print(f" - {f.path} (relevance: {f.relevance_score:.2f})")
# Example 3: Search for imports
print("\n3. Search for Specific Imports")
print("-" * 40)
result = fc.search("from typing import")
print(f" Query: 'from typing import'")
print(f" Files found: {result.total_files}")
print(f" Search time: {result.search_time_ms}ms")
# Example 4: Get formatted context string
print("\n4. Get Formatted Context for Agent")
print("-" * 40)
context = fc.get_context_for_agent("authentication", max_files=3)
print(f" Context length: {len(context)} characters")
print(f" Preview:")
preview = context[:300].replace('\n', '\n ')
print(f" {preview}...")
print("\n" + "=" * 70)
print("Fast Context provides rapid, parallel code search!")
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,113 @@
"""
Example 2: Parallel vs Sequential Search Comparison
This example demonstrates the speed advantage of Fast Context's parallel
execution compared to traditional sequential search.
Key insight: Fast Context executes up to 8 searches simultaneously,
making it 5-10x faster for complex queries.
"""
import time
import os
from praisonaiagents.context.fast import FastContext
from praisonaiagents.context.fast.search_tools import grep_search, glob_search
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def sequential_search(queries: list) -> dict:
"""Traditional sequential search - one query at a time."""
results = {}
for query in queries:
results[query] = grep_search(
search_path=WORKSPACE,
pattern=query,
max_results=20
)
return results
def parallel_search_with_fast_context(queries: list) -> dict:
"""Fast Context parallel search - multiple queries at once."""
fc = FastContext(workspace_path=WORKSPACE)
results = {}
for query in queries:
result = fc.search(query)
results[query] = result
return results
def main():
print("=" * 70)
print("Parallel vs Sequential Search Comparison")
print("=" * 70)
# Define multiple search queries
queries = [
"def __init__",
"class Agent",
"async def",
"import logging",
"from typing",
]
print(f"\nSearching for {len(queries)} patterns in the codebase...")
print(f"Workspace: {WORKSPACE}")
# Sequential search
print("\n1. Sequential Search (Traditional)")
print("-" * 40)
start = time.perf_counter()
seq_results = sequential_search(queries)
seq_time = (time.perf_counter() - start) * 1000
total_seq_matches = sum(len(r) for r in seq_results.values())
print(f" Total matches: {total_seq_matches}")
print(f" Time: {seq_time:.0f}ms")
# Fast Context search (with caching disabled for fair comparison)
print("\n2. Fast Context Search (Parallel)")
print("-" * 40)
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
start = time.perf_counter()
fc_results = {}
for query in queries:
fc_results[query] = fc.search(query)
fc_time = (time.perf_counter() - start) * 1000
total_fc_matches = sum(r.total_files for r in fc_results.values())
print(f" Total files found: {total_fc_matches}")
print(f" Time: {fc_time:.0f}ms")
# Comparison
print("\n3. Comparison")
print("-" * 40)
speedup = seq_time / fc_time if fc_time > 0 else float('inf')
print(f" Sequential: {seq_time:.0f}ms")
print(f" Fast Context: {fc_time:.0f}ms")
print(f" Speedup: {speedup:.1f}x faster")
# Detailed results
print("\n4. Results by Query")
print("-" * 40)
for query in queries:
seq_count = len(seq_results[query])
fc_count = fc_results[query].total_files
fc_ms = fc_results[query].search_time_ms
print(f" '{query}':")
print(f" Sequential: {seq_count} matches")
print(f" Fast Context: {fc_count} files in {fc_ms}ms")
print("\n" + "=" * 70)
print(f"Fast Context is {speedup:.1f}x faster due to parallel execution!")
print("=" * 70)
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""
Example 3: Caching Performance Demonstration
This example shows how Fast Context's caching dramatically improves
performance for repeated queries.
Key benefits:
- First query: Full search execution
- Subsequent queries: Instant cache hit (0ms search time)
- Configurable TTL (time-to-live)
"""
import time
from praisonaiagents.context.fast import FastContext
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def main():
print("=" * 70)
print("Fast Context Caching Performance")
print("=" * 70)
# Create FastContext with caching enabled
fc = FastContext(
workspace_path=WORKSPACE,
cache_enabled=True,
cache_ttl=300 # 5 minutes
)
query = "def execute"
print(f"\nQuery: '{query}'")
print(f"Cache TTL: 300 seconds")
# First search - cache miss
print("\n1. First Search (Cache Miss)")
print("-" * 40)
start = time.perf_counter()
result1 = fc.search(query)
elapsed1 = (time.perf_counter() - start) * 1000
print(f" Files found: {result1.total_files}")
print(f" From cache: {result1.from_cache}")
print(f" Search time: {result1.search_time_ms}ms")
print(f" Total time: {elapsed1:.1f}ms")
# Second search - cache hit
print("\n2. Second Search (Cache Hit)")
print("-" * 40)
start = time.perf_counter()
result2 = fc.search(query)
elapsed2 = (time.perf_counter() - start) * 1000
print(f" Files found: {result2.total_files}")
print(f" From cache: {result2.from_cache}")
print(f" Search time: {result2.search_time_ms}ms")
print(f" Total time: {elapsed2:.1f}ms")
# Third search - still cached
print("\n3. Third Search (Still Cached)")
print("-" * 40)
start = time.perf_counter()
result3 = fc.search(query)
elapsed3 = (time.perf_counter() - start) * 1000
print(f" Files found: {result3.total_files}")
print(f" From cache: {result3.from_cache}")
print(f" Total time: {elapsed3:.1f}ms")
# Clear cache and search again
print("\n4. After Cache Clear")
print("-" * 40)
fc.clear_cache()
print(" Cache cleared!")
start = time.perf_counter()
result4 = fc.search(query)
elapsed4 = (time.perf_counter() - start) * 1000
print(f" Files found: {result4.total_files}")
print(f" From cache: {result4.from_cache}")
print(f" Search time: {result4.search_time_ms}ms")
print(f" Total time: {elapsed4:.1f}ms")
# Performance comparison
print("\n5. Performance Summary")
print("-" * 40)
cache_speedup = elapsed1 / elapsed2 if elapsed2 > 0 else float('inf')
print(f" First search (no cache): {elapsed1:.1f}ms")
print(f" Cached searches: ~{elapsed2:.1f}ms")
print(f" Cache speedup: {cache_speedup:.0f}x faster")
# Multiple different queries
print("\n6. Multiple Query Caching")
print("-" * 40)
queries = ["class Agent", "async def", "import os", "def __init__"]
# First pass - populate cache
print(" First pass (populating cache):")
start = time.perf_counter()
for q in queries:
fc.search(q)
first_pass = (time.perf_counter() - start) * 1000
print(f" Time: {first_pass:.0f}ms")
# Second pass - all cached
print(" Second pass (all cached):")
start = time.perf_counter()
for q in queries:
r = fc.search(q)
assert r.from_cache, f"Expected cache hit for '{q}'"
second_pass = (time.perf_counter() - start) * 1000
print(f" Time: {second_pass:.1f}ms")
multi_speedup = first_pass / second_pass if second_pass > 0 else float('inf')
print(f" Speedup: {multi_speedup:.0f}x faster")
print("\n" + "=" * 70)
print("Caching provides instant results for repeated queries!")
print("=" * 70)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""
Example 4: Agent Integration with Fast Context
This example shows how to use Fast Context integrated with the Agent class
for automatic code search delegation.
Benefits:
- Seamless integration with existing Agent workflows
- Automatic context injection for code-related queries
- Configurable via Agent parameters or environment variables
"""
from praisonaiagents import Agent
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def main():
print("=" * 70)
print("Agent Integration with Fast Context")
print("=" * 70)
# Create an agent with Fast Context enabled
print("\n1. Creating Agent with Fast Context")
print("-" * 40)
agent = Agent(
name="CodeAssistant",
instructions="You are a helpful code assistant that can search and analyze code.",
fast_context=True,
fast_context_path=WORKSPACE,
fast_context_model="gpt-4o-mini",
fast_context_max_turns=4,
fast_context_parallelism=8,
fast_context_timeout=30.0,
verbose=False
)
print(f" Agent name: {agent.name}")
print(f" Fast Context enabled: {agent.fast_context_enabled}")
print(f" Fast Context path: {agent.fast_context_path}")
print(f" Fast Context model: {agent.fast_context_model}")
print(f" Max turns: {agent.fast_context_max_turns}")
print(f" Parallelism: {agent.fast_context_parallelism}")
# Use delegate_to_fast_context for code search
print("\n2. Delegating Code Search to Fast Context")
print("-" * 40)
queries = [
"find the Agent class definition",
"search for authentication handlers",
"locate memory management code",
]
for query in queries:
print(f"\n Query: '{query}'")
context = agent.delegate_to_fast_context(query)
if context:
# Show preview of context
lines = context.split('\n')
preview_lines = lines[:5]
print(f" Found context ({len(context)} chars):")
for line in preview_lines:
print(f" {line[:60]}{'...' if len(line) > 60 else ''}")
if len(lines) > 5:
print(f" ... and {len(lines) - 5} more lines")
else:
print(" No relevant code found")
# Access the FastContext instance directly
print("\n3. Direct FastContext Access")
print("-" * 40)
fc = agent.fast_context
if fc:
result = fc.search("def chat")
print(f" Direct search for 'def chat':")
print(f" Files found: {result.total_files}")
print(f" Search time: {result.search_time_ms}ms")
for f in result.files[:3]:
print(f" - {f.path}")
# Show configuration options
print("\n4. Configuration Options")
print("-" * 40)
print(" Agent parameters:")
print(" - fast_context=True # Enable Fast Context")
print(" - fast_context_path='.' # Workspace path")
print(" - fast_context_model='gpt-4o-mini' # Search model")
print(" - fast_context_max_turns=4 # Max search turns")
print(" - fast_context_parallelism=8 # Parallel calls")
print(" - fast_context_timeout=30.0 # Timeout (seconds)")
print("\n Environment variables:")
print(" - FAST_CONTEXT_MODEL")
print(" - FAST_CONTEXT_MAX_TURNS")
print(" - FAST_CONTEXT_PARALLELISM")
print(" - FAST_CONTEXT_TIMEOUT")
print(" - FAST_CONTEXT_CACHE")
print(" - FAST_CONTEXT_CACHE_TTL")
print("\n" + "=" * 70)
print("Fast Context integrates seamlessly with Agent for code search!")
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,121 @@
"""
Example 5: File and Symbol Indexing
This example demonstrates the file and symbol indexing capabilities
that power Fast Context's rapid search.
Features:
- FileIndexer: Index file metadata for fast lookups
- SymbolIndexer: Extract code symbols (functions, classes, imports)
- Support for Python, JavaScript, TypeScript, Go, Rust, Java
"""
import time
from praisonaiagents.context.fast.indexer import (
FileIndexer,
SymbolIndexer,
SymbolType
)
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def main():
print("=" * 70)
print("File and Symbol Indexing")
print("=" * 70)
# File Indexer
print("\n1. File Indexer")
print("-" * 40)
start = time.perf_counter()
file_indexer = FileIndexer(workspace_path=WORKSPACE)
file_count = file_indexer.index()
file_time = (time.perf_counter() - start) * 1000
print(f" Indexed {file_count} files in {file_time:.0f}ms")
stats = file_indexer.get_stats()
print(f" Total size: {stats['total_size_mb']} MB")
print(f" Extensions: {list(stats['extensions'].keys())[:5]}")
# Find files by pattern
print("\n Finding files by pattern:")
py_files = file_indexer.find_by_pattern("**/*.py")
print(f" - Python files (*.py): {len(py_files)}")
agent_files = file_indexer.find_by_pattern("**/agent*.py")
print(f" - Agent files (agent*.py): {len(agent_files)}")
# Find files by name
print("\n Finding files by name:")
init_files = file_indexer.find_by_name("__init__.py", exact=True)
print(f" - __init__.py files: {len(init_files)}")
agent_matches = file_indexer.find_by_name("agent")
print(f" - Files containing 'agent': {len(agent_matches)}")
# Symbol Indexer
print("\n2. Symbol Indexer")
print("-" * 40)
start = time.perf_counter()
symbol_indexer = SymbolIndexer(workspace_path=WORKSPACE)
symbol_count = symbol_indexer.index()
symbol_time = (time.perf_counter() - start) * 1000
print(f" Indexed {symbol_count} symbols in {symbol_time:.0f}ms")
stats = symbol_indexer.get_stats()
print(f" Files indexed: {stats['total_files']}")
print(f" Symbols by type:")
for sym_type, count in stats['by_type'].items():
print(f" - {sym_type}: {count}")
# Find symbols by name
print("\n Finding symbols by name:")
agent_symbols = symbol_indexer.find_by_name("Agent", exact=True)
print(f" - 'Agent' (exact): {len(agent_symbols)} symbols")
for sym in agent_symbols[:3]:
print(f" {sym.symbol_type.value}: {sym.name} in {sym.file_path}:{sym.line_number}")
chat_symbols = symbol_indexer.find_by_name("chat")
print(f" - 'chat' (partial): {len(chat_symbols)} symbols")
for sym in chat_symbols[:3]:
print(f" {sym.symbol_type.value}: {sym.name} in {sym.file_path}:{sym.line_number}")
# Find symbols by type
print("\n Finding symbols by type:")
classes = symbol_indexer.find_by_type(SymbolType.CLASS)
print(f" - Classes: {len(classes)}")
for cls in classes[:5]:
print(f" class {cls.name} in {cls.file_path}:{cls.line_number}")
functions = symbol_indexer.find_by_type(SymbolType.FUNCTION)
print(f" - Functions: {len(functions)}")
methods = symbol_indexer.find_by_type(SymbolType.METHOD)
print(f" - Methods: {len(methods)}")
imports = symbol_indexer.find_by_type(SymbolType.IMPORT)
print(f" - Imports: {len(imports)}")
# Performance comparison
print("\n3. Performance Summary")
print("-" * 40)
print(f" File indexing: {file_time:.0f}ms for {file_count} files")
print(f" Symbol indexing: {symbol_time:.0f}ms for {symbol_count} symbols")
print(f" Files per second: {file_count / (file_time / 1000):.0f}")
print(f" Symbols per second: {symbol_count / (symbol_time / 1000):.0f}")
print("\n" + "=" * 70)
print("Indexing enables instant lookups for files and symbols!")
print("=" * 70)
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
"""
Example 6: Context Injection for Agents
This example demonstrates how Fast Context results can be formatted
and injected into agent prompts for better code understanding.
Features:
- Multiple format styles (markdown, xml, plain)
- Token budget management
- Precision over recall (avoid context pollution)
"""
from praisonaiagents.context.fast import FastContext
from praisonaiagents.context.fast.context_injector import (
ContextInjector,
InjectionConfig,
inject_fast_context
)
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def main():
print("=" * 70)
print("Context Injection for Agents")
print("=" * 70)
# Search for code
fc = FastContext(workspace_path=WORKSPACE)
result = fc.search("class Agent")
print(f"\nSearch results: {result.total_files} files found")
# Markdown format (default)
print("\n1. Markdown Format")
print("-" * 40)
config_md = InjectionConfig(
max_tokens=2000,
max_files=3,
max_lines_per_file=20,
format_style="markdown",
include_line_numbers=True,
include_file_content=True,
prioritize_precision=True
)
injector_md = ContextInjector(config_md)
md_context = injector_md.format_context(result)
print(f" Format: Markdown")
print(f" Length: {len(md_context)} chars (~{len(md_context)//4} tokens)")
print(f" Preview:")
for line in md_context.split('\n')[:10]:
print(f" {line[:70]}")
print(" ...")
# XML format
print("\n2. XML Format")
print("-" * 40)
config_xml = InjectionConfig(
max_tokens=2000,
max_files=3,
format_style="xml",
prioritize_precision=True
)
injector_xml = ContextInjector(config_xml)
xml_context = injector_xml.format_context(result)
print(f" Format: XML")
print(f" Length: {len(xml_context)} chars")
print(f" Preview:")
for line in xml_context.split('\n')[:10]:
print(f" {line[:70]}")
print(" ...")
# Plain text format
print("\n3. Plain Text Format")
print("-" * 40)
config_plain = InjectionConfig(
max_tokens=2000,
max_files=3,
format_style="plain"
)
injector_plain = ContextInjector(config_plain)
plain_context = injector_plain.format_context(result)
print(f" Format: Plain")
print(f" Length: {len(plain_context)} chars")
print(f" Preview:")
for line in plain_context.split('\n')[:8]:
print(f" {line[:70]}")
print(" ...")
# Inject into prompts
print("\n4. Injecting into Prompts")
print("-" * 40)
system_prompt = "You are a helpful code assistant."
user_message = "Explain how the Agent class works."
injected = inject_fast_context(
result=result,
system_prompt=system_prompt,
user_message=user_message,
max_tokens=1500,
format_style="markdown"
)
print(f" Original system prompt: {len(system_prompt)} chars")
print(f" Injected system prompt: {len(injected['system_prompt'])} chars")
print(f" User message unchanged: {injected['user_message'] == user_message}")
# Token budget management
print("\n5. Token Budget Management")
print("-" * 40)
budgets = [500, 1000, 2000, 4000]
for budget in budgets:
config = InjectionConfig(max_tokens=budget, max_files=10)
injector = ContextInjector(config)
context = injector.format_context(result)
estimated_tokens = len(context) // 4
print(f" Budget: {budget} tokens -> {len(context)} chars (~{estimated_tokens} tokens)")
# Precision mode
print("\n6. Precision vs Recall")
print("-" * 40)
# High precision (fewer, more relevant results)
config_precision = InjectionConfig(
max_tokens=2000,
max_files=5,
prioritize_precision=True
)
injector_precision = ContextInjector(config_precision)
precision_context = injector_precision.format_context(result)
# Lower precision (more results)
config_recall = InjectionConfig(
max_tokens=2000,
max_files=10,
prioritize_precision=False
)
injector_recall = ContextInjector(config_recall)
recall_context = injector_recall.format_context(result)
print(f" High precision mode: {len(precision_context)} chars")
print(f" High recall mode: {len(recall_context)} chars")
print(f" Precision mode is more focused for better agent understanding")
print("\n" + "=" * 70)
print("Context injection formats search results for optimal agent use!")
print("=" * 70)
if __name__ == "__main__":
main()
+213
View File
@@ -0,0 +1,213 @@
"""
Example 7: Search Tools Deep Dive
This example demonstrates the individual search tools that power Fast Context:
- grep_search: Pattern-based search with regex support
- glob_search: File pattern matching
- read_file: Read file contents with line ranges
- list_directory: Directory listing with filtering
"""
import time
from praisonaiagents.context.fast.search_tools import (
grep_search,
glob_search,
read_file,
list_directory
)
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def main():
print("=" * 70)
print("Search Tools Deep Dive")
print("=" * 70)
# grep_search
print("\n1. grep_search - Pattern Search")
print("-" * 40)
# Simple pattern
start = time.perf_counter()
results = grep_search(
search_path=WORKSPACE,
pattern="def __init__",
max_results=10
)
elapsed = (time.perf_counter() - start) * 1000
print(f" Pattern: 'def __init__'")
print(f" Matches: {len(results)}")
print(f" Time: {elapsed:.0f}ms")
if results:
print(f" First match: {results[0]['path']}:{results[0]['line_number']}")
# Regex pattern
results = grep_search(
search_path=WORKSPACE,
pattern=r"class \w+Agent",
is_regex=True,
max_results=10
)
print(f"\n Regex: 'class \\w+Agent'")
print(f" Matches: {len(results)}")
for r in results[:3]:
print(f" - {r['path']}:{r['line_number']}: {r['content'][:50]}...")
# Case sensitive
results_sensitive = grep_search(
search_path=WORKSPACE,
pattern="Agent",
case_sensitive=True,
max_results=50
)
results_insensitive = grep_search(
search_path=WORKSPACE,
pattern="agent",
case_sensitive=False,
max_results=50
)
print(f"\n Case sensitive 'Agent': {len(results_sensitive)} matches")
print(f" Case insensitive 'agent': {len(results_insensitive)} matches")
# With context lines
results = grep_search(
search_path=WORKSPACE,
pattern="class Agent:",
context_lines=2,
max_results=3
)
print(f"\n With 2 context lines:")
if results:
print(f" {results[0]['path']}:")
if results[0].get('context'):
for line in results[0]['context'].split('\n')[:5]:
print(f" {line[:60]}")
# glob_search
print("\n2. glob_search - File Pattern Matching")
print("-" * 40)
# Find Python files
start = time.perf_counter()
results = glob_search(
search_path=WORKSPACE,
pattern="**/*.py",
max_results=100
)
elapsed = (time.perf_counter() - start) * 1000
print(f" Pattern: '**/*.py'")
print(f" Files found: {len(results)}")
print(f" Time: {elapsed:.0f}ms")
# Find specific files
results = glob_search(
search_path=WORKSPACE,
pattern="**/agent*.py",
max_results=20
)
print(f"\n Pattern: '**/agent*.py'")
print(f" Files found: {len(results)}")
for r in results[:5]:
print(f" - {r['path']} ({r['size']} bytes)")
# Find test files
results = glob_search(
search_path=WORKSPACE,
pattern="**/test_*.py",
max_results=50
)
print(f"\n Pattern: '**/test_*.py'")
print(f" Test files: {len(results)}")
# read_file
print("\n3. read_file - Read File Contents")
print("-" * 40)
# Read entire file
result = read_file(
filepath=f"{WORKSPACE}/praisonaiagents/__init__.py",
max_lines=20
)
print(f" File: praisonaiagents/__init__.py")
print(f" Success: {result['success']}")
print(f" Lines: {result.get('total_lines', 'N/A')}")
if result['success']:
lines = result['content'].split('\n')[:5]
print(f" Preview:")
for line in lines:
print(f" {line[:60]}")
# Read specific line range
result = read_file(
filepath=f"{WORKSPACE}/praisonaiagents/agent/agent.py",
start_line=1,
end_line=20
)
print(f"\n File: agent/agent.py (lines 1-20)")
print(f" Success: {result['success']}")
if result['success']:
lines = result['content'].split('\n')[:5]
print(f" Preview:")
for line in lines:
print(f" {line[:60]}")
# Read with context
result = read_file(
filepath=f"{WORKSPACE}/praisonaiagents/agent/agent.py",
start_line=100,
end_line=105,
context_lines=3
)
print(f"\n File: agent/agent.py (lines 100-105 with 3 context lines)")
print(f" Success: {result['success']}")
# list_directory
print("\n4. list_directory - Directory Listing")
print("-" * 40)
# List root
result = list_directory(
dir_path=WORKSPACE,
recursive=False,
max_entries=20
)
print(f" Directory: {WORKSPACE}")
print(f" Success: {result['success']}")
print(f" Entries: {len(result.get('entries', []))}")
dirs = [e for e in result.get('entries', []) if e.get('is_dir')]
files = [e for e in result.get('entries', []) if not e.get('is_dir')]
print(f" Directories: {len(dirs)}")
print(f" Files: {len(files)}")
# List recursive with depth limit
result = list_directory(
dir_path=f"{WORKSPACE}/praisonaiagents",
recursive=True,
max_depth=2,
max_entries=50
)
print(f"\n Directory: praisonaiagents (recursive, depth=2)")
print(f" Entries: {len(result.get('entries', []))}")
# Show directory structure
print(f" Structure:")
for entry in result.get('entries', [])[:10]:
prefix = "📁" if entry.get('is_dir') else "📄"
print(f" {prefix} {entry['name']}")
print("\n" + "=" * 70)
print("These search tools power Fast Context's rapid code search!")
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,227 @@
"""
Example 8: Comprehensive Benchmark
This example provides a comprehensive benchmark comparing Fast Context
performance against traditional search approaches.
Metrics measured:
- Search latency
- Throughput (queries per second)
- Cache effectiveness
- Parallel execution speedup
"""
import time
import statistics
from praisonaiagents.context.fast import FastContext
from praisonaiagents.context.fast.search_tools import grep_search
from praisonaiagents.context.fast.indexer import FileIndexer, SymbolIndexer
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
def benchmark_search_latency():
"""Benchmark search latency."""
print("\n1. Search Latency Benchmark")
print("-" * 50)
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
queries = [
"def __init__",
"class Agent",
"import logging",
"async def",
"from typing import",
]
# Warm up
fc.search("warmup")
# Benchmark each query
latencies = []
for query in queries:
times = []
for _ in range(3): # 3 runs per query
start = time.perf_counter()
fc.search(query)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
avg = statistics.mean(times)
latencies.append(avg)
print(f" '{query}': {avg:.1f}ms (min: {min(times):.1f}, max: {max(times):.1f})")
print(f"\n Average latency: {statistics.mean(latencies):.1f}ms")
print(f" Median latency: {statistics.median(latencies):.1f}ms")
print(f" P95 latency: {sorted(latencies)[int(len(latencies)*0.95)]:.1f}ms")
def benchmark_throughput():
"""Benchmark queries per second."""
print("\n2. Throughput Benchmark")
print("-" * 50)
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=True)
queries = ["def", "class", "import", "return", "if", "for", "while", "try"]
# Cold run (no cache)
fc.clear_cache()
start = time.perf_counter()
for query in queries:
fc.search(query)
cold_time = time.perf_counter() - start
cold_qps = len(queries) / cold_time
print(f" Cold (no cache):")
print(f" Queries: {len(queries)}")
print(f" Time: {cold_time*1000:.0f}ms")
print(f" Throughput: {cold_qps:.1f} queries/second")
# Warm run (cached)
start = time.perf_counter()
for _ in range(10): # 10 iterations
for query in queries:
fc.search(query)
warm_time = time.perf_counter() - start
warm_qps = (len(queries) * 10) / warm_time
print(f"\n Warm (cached):")
print(f" Queries: {len(queries) * 10}")
print(f" Time: {warm_time*1000:.0f}ms")
print(f" Throughput: {warm_qps:.0f} queries/second")
print(f"\n Cache speedup: {warm_qps/cold_qps:.0f}x")
def benchmark_cache_effectiveness():
"""Benchmark cache hit rate and speedup."""
print("\n3. Cache Effectiveness Benchmark")
print("-" * 50)
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=True)
fc.clear_cache()
queries = ["Agent", "chat", "memory", "tool", "execute"]
# First pass - cache misses
miss_times = []
for query in queries:
start = time.perf_counter()
result = fc.search(query)
elapsed = (time.perf_counter() - start) * 1000
miss_times.append(elapsed)
assert not result.from_cache
# Second pass - cache hits
hit_times = []
for query in queries:
start = time.perf_counter()
result = fc.search(query)
elapsed = (time.perf_counter() - start) * 1000
hit_times.append(elapsed)
assert result.from_cache
avg_miss = statistics.mean(miss_times)
avg_hit = statistics.mean(hit_times)
print(f" Cache miss (first query): {avg_miss:.1f}ms avg")
print(f" Cache hit (repeat query): {avg_hit:.2f}ms avg")
print(f" Cache speedup: {avg_miss/avg_hit:.0f}x faster")
print(f" Time saved per cached query: {avg_miss - avg_hit:.1f}ms")
def benchmark_parallel_execution():
"""Benchmark parallel vs sequential execution."""
print("\n4. Parallel Execution Benchmark")
print("-" * 50)
queries = ["def", "class", "import", "return", "async"]
# Sequential (one at a time)
start = time.perf_counter()
for query in queries:
grep_search(WORKSPACE, query, max_results=20)
seq_time = (time.perf_counter() - start) * 1000
# Parallel (Fast Context)
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
start = time.perf_counter()
for query in queries:
fc.search(query)
par_time = (time.perf_counter() - start) * 1000
print(f" Sequential: {seq_time:.0f}ms")
print(f" Parallel (Fast Context): {par_time:.0f}ms")
print(f" Speedup: {seq_time/par_time:.1f}x")
def benchmark_indexing():
"""Benchmark indexing performance."""
print("\n5. Indexing Performance Benchmark")
print("-" * 50)
# File indexing
start = time.perf_counter()
file_indexer = FileIndexer(workspace_path=WORKSPACE)
file_count = file_indexer.index()
file_time = (time.perf_counter() - start) * 1000
print(f" File Indexer:")
print(f" Files: {file_count}")
print(f" Time: {file_time:.0f}ms")
print(f" Rate: {file_count/(file_time/1000):.0f} files/second")
# Symbol indexing
start = time.perf_counter()
symbol_indexer = SymbolIndexer(workspace_path=WORKSPACE)
symbol_count = symbol_indexer.index()
symbol_time = (time.perf_counter() - start) * 1000
print(f"\n Symbol Indexer:")
print(f" Symbols: {symbol_count}")
print(f" Time: {symbol_time:.0f}ms")
print(f" Rate: {symbol_count/(symbol_time/1000):.0f} symbols/second")
# Lookup performance
start = time.perf_counter()
for _ in range(100):
file_indexer.find_by_pattern("**/*.py")
lookup_time = (time.perf_counter() - start) * 1000
print(f"\n Lookup Performance:")
print(f" 100 pattern lookups: {lookup_time:.1f}ms")
print(f" Per lookup: {lookup_time/100:.2f}ms")
def main():
print("=" * 70)
print("Fast Context Comprehensive Benchmark")
print("=" * 70)
print(f"\nWorkspace: {WORKSPACE}")
benchmark_search_latency()
benchmark_throughput()
benchmark_cache_effectiveness()
benchmark_parallel_execution()
benchmark_indexing()
print("\n" + "=" * 70)
print("Summary")
print("=" * 70)
print("""
Fast Context provides:
✓ Sub-100ms search latency for most queries
✓ 100+ queries/second with caching
✓ 100x+ speedup for cached queries
✓ 2-5x speedup from parallel execution
✓ 1000+ files/second indexing rate
These benchmarks demonstrate why Fast Context is ideal for
rapid code search in AI agent workflows.
""")
if __name__ == "__main__":
main()
@@ -29,7 +29,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
@@ -71,7 +71,6 @@
"import pandas as pd\n",
"\n",
"print(\"📦 Dependencies installed!\")\n",
"print(\"🔄 Converting from AGNO → PraisonAI\")\n",
"print(\"🎯 Building Job Hunting Agent System\")"
]
},
@@ -0,0 +1,82 @@
"""MCP Schema Converter Example
Demonstrates how to convert Python functions to MCP-compatible schemas.
Usage:
python mcp-schema-converter.py
"""
from typing import List, Dict, Any, Optional
from praisonaiagents.mcp import function_to_mcp_schema, get_tool_metadata
# Example functions with various type annotations
def simple_search(query: str) -> str:
"""Search for information."""
return f"Results for {query}"
def advanced_search(
query: str,
max_results: int = 10,
include_images: bool = False,
filters: Optional[Dict[str, str]] = None
) -> Dict[str, Any]:
"""Advanced search with multiple options.
Args:
query: The search query
max_results: Maximum results to return
include_images: Whether to include images
filters: Optional filters to apply
"""
return {}
def process_items(items: List[str], batch_size: int = 5) -> List[Dict[str, Any]]:
"""Process a list of items.
Args:
items: List of items to process
batch_size: Size of each batch
"""
return []
async def async_fetch(url: str, timeout: float = 30.0) -> Dict[str, Any]:
"""Fetch data from URL asynchronously.
Args:
url: The URL to fetch
timeout: Request timeout in seconds
"""
return {}
def main():
functions = [simple_search, advanced_search, process_items, async_fetch]
print("=" * 60)
print("MCP Schema Converter Demo")
print("=" * 60)
for func in functions:
print(f"\n### Function: {func.__name__}")
# Get metadata
metadata = get_tool_metadata(func)
print(f"Name: {metadata['name']}")
print(f"Description: {metadata['description']}")
# Get full schema
schema = function_to_mcp_schema(func)
print("\nMCP Schema:")
import json
print(json.dumps(schema, indent=2))
print("-" * 40)
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
"""MCP Tools Server with Async Functions
Example showing how to expose async Python functions as MCP tools.
Usage:
python mcp-tools-server-async.py
"""
import asyncio
from praisonaiagents.mcp import ToolsMCPServer
async def async_search(query: str, delay: float = 0.1) -> dict:
"""Async search function.
Args:
query: The search query
delay: Simulated delay in seconds
"""
await asyncio.sleep(delay) # Simulate async operation
return {
"query": query,
"results": [f"Async result for {query}"]
}
async def async_fetch_url(url: str) -> dict:
"""Fetch content from a URL asynchronously.
Args:
url: The URL to fetch
"""
# Mock implementation - use aiohttp in production
await asyncio.sleep(0.1)
return {
"url": url,
"status": 200,
"content": f"Content from {url}"
}
async def async_process_data(data: str, iterations: int = 3) -> dict:
"""Process data asynchronously with multiple iterations.
Args:
data: The data to process
iterations: Number of processing iterations
"""
results = []
for i in range(iterations):
await asyncio.sleep(0.05)
results.append(f"Iteration {i+1}: processed {data}")
return {
"input": data,
"iterations": iterations,
"results": results
}
# Sync function can also be mixed with async
def sync_calculate(a: int, b: int) -> dict:
"""Synchronous calculation (can be mixed with async tools)."""
return {"sum": a + b, "product": a * b}
if __name__ == "__main__":
server = ToolsMCPServer(name="async-tools-server")
# Register both async and sync tools
server.register_tools([
async_search,
async_fetch_url,
async_process_data,
sync_calculate
])
print(f"Registered tools: {server.get_tool_names()}")
server.run()
@@ -0,0 +1,27 @@
"""MCP Server with Built-in PraisonAI Tools
Expose PraisonAI's built-in tools as an MCP server.
Usage:
# Set required API keys first
export TAVILY_API_KEY=your_key
python mcp-tools-server-builtin.py
"""
from praisonaiagents.mcp import launch_tools_mcp_server
if __name__ == "__main__":
# Expose built-in PraisonAI tools as MCP server
# This allows external MCP clients to use these tools
launch_tools_mcp_server(
tool_names=[
"tavily_search", # Web search via Tavily
"duckduckgo_search", # Web search via DuckDuckGo
# Add more built-in tools as needed
],
name="praisonai-builtin-tools",
transport="stdio" # Use "sse" for web clients
)
@@ -0,0 +1,25 @@
"""Simple MCP Tools Server Example
Minimal example of exposing Python functions as MCP tools.
Usage:
python mcp-tools-server-simple.py
"""
from praisonaiagents.mcp import ToolsMCPServer
def search(query: str) -> dict:
"""Search for information."""
return {"query": query, "results": [f"Result for {query}"]}
def add(a: int, b: int) -> dict:
"""Add two numbers."""
return {"result": a + b}
if __name__ == "__main__":
server = ToolsMCPServer(name="simple-tools")
server.register_tools([search, add])
server.run()
@@ -0,0 +1,65 @@
"""MCP Tools Server with SSE Transport
Expose Python functions as MCP tools via Server-Sent Events (SSE).
This is useful for web-based clients and remote access.
Usage:
python mcp-tools-server-sse.py
# Then connect from another agent:
# agent = Agent(tools=MCP("http://localhost:8080/sse"))
"""
from praisonaiagents.mcp import ToolsMCPServer
def search_web(query: str, max_results: int = 5) -> dict:
"""Search the web for information.
Args:
query: The search query
max_results: Maximum results to return
"""
return {
"query": query,
"results": [
{"title": f"Result {i+1}", "url": f"https://example.com/{i+1}"}
for i in range(max_results)
]
}
def calculate(expression: str) -> dict:
"""Evaluate a mathematical expression.
Args:
expression: Math expression (e.g., "2 + 2 * 3")
"""
try:
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return {"error": "Invalid characters"}
return {"expression": expression, "result": eval(expression)}
except Exception as e:
return {"error": str(e)}
def get_time() -> dict:
"""Get the current time."""
from datetime import datetime
now = datetime.now()
return {
"time": now.strftime("%H:%M:%S"),
"date": now.strftime("%Y-%m-%d"),
"timestamp": now.isoformat()
}
if __name__ == "__main__":
server = ToolsMCPServer(name="sse-tools-server")
server.register_tools([search_web, calculate, get_time])
print("Starting SSE MCP Server...")
print("Connect with: MCP('http://localhost:8080/sse')")
server.run_sse(host="0.0.0.0", port=8080)
+140
View File
@@ -0,0 +1,140 @@
"""MCP Tools Server Example
Expose Python functions as MCP tools for external clients like Claude Desktop, Cursor, etc.
Usage:
# Run as stdio server (for Claude Desktop)
python mcp-tools-server.py
# Run as SSE server (for web clients)
python mcp-tools-server.py --sse --port 8080
"""
import argparse
from typing import List, Dict, Any
# Define your custom tools as regular Python functions
# Type hints and docstrings are used to generate MCP schemas
def search_web(query: str, max_results: int = 5) -> Dict[str, Any]:
"""Search the web for information.
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
Returns:
Search results with titles and URLs
"""
# Mock implementation - replace with actual search logic
return {
"query": query,
"results": [
{"title": f"Result {i+1} for '{query}'", "url": f"https://example.com/{i+1}"}
for i in range(max_results)
]
}
def calculate(expression: str) -> Dict[str, Any]:
"""Evaluate a mathematical expression.
Args:
expression: A mathematical expression to evaluate (e.g., "2 + 2 * 3")
Returns:
The result of the calculation
"""
try:
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Use safer alternative in production
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
def get_weather(city: str, units: str = "celsius") -> Dict[str, Any]:
"""Get current weather for a city.
Args:
city: Name of the city
units: Temperature units - "celsius" or "fahrenheit" (default: celsius)
Returns:
Weather information for the city
"""
# Mock implementation - replace with actual weather API
return {
"city": city,
"temperature": 22 if units == "celsius" else 72,
"units": units,
"condition": "Sunny",
"humidity": 45
}
def list_files(directory: str = ".", pattern: str = "*") -> List[str]:
"""List files in a directory.
Args:
directory: Directory path to list (default: current directory)
pattern: Glob pattern to filter files (default: *)
Returns:
List of file names matching the pattern
"""
import glob
import os
try:
full_pattern = os.path.join(directory, pattern)
files = glob.glob(full_pattern)
return [os.path.basename(f) for f in files]
except Exception as e:
return [f"Error: {str(e)}"]
def main():
parser = argparse.ArgumentParser(description="Run PraisonAI Tools as MCP Server")
parser.add_argument("--sse", action="store_true", help="Use SSE transport instead of stdio")
parser.add_argument("--port", type=int, default=8080, help="Port for SSE server (default: 8080)")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host for SSE server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
from praisonaiagents.mcp import ToolsMCPServer
# Create server with custom name
server = ToolsMCPServer(
name="praisonai-custom-tools",
debug=args.debug
)
# Register all tools
server.register_tools([
search_web,
calculate,
get_weather,
list_files
])
# Print registered tools
print(f"📦 Registered {len(server.tools)} tools:")
for name in server.get_tool_names():
print(f" - {name}")
print()
# Run the server
if args.sse:
server.run_sse(host=args.host, port=args.port)
else:
server.run_stdio()
if __name__ == "__main__":
main()
@@ -0,0 +1,36 @@
"""
Simple Workflow Example
The easiest way to create and run workflows in PraisonAI.
Just pass functions or agents as steps - no complex configuration needed!
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Define simple handler functions
def validate(ctx: WorkflowContext) -> StepResult:
"""Validate the input data."""
if not ctx.input:
return StepResult(output="No input provided", stop_workflow=True)
return StepResult(output=f"Validated: {ctx.input}")
def process(ctx: WorkflowContext) -> StepResult:
"""Process the validated data."""
return StepResult(output=f"Processed: {ctx.previous_result}")
def format_output(ctx: WorkflowContext) -> StepResult:
"""Format the final output."""
return StepResult(output=f"✅ Final: {ctx.previous_result}")
# Create workflow - just list your functions!
workflow = Workflow(
name="Simple Pipeline",
steps=[validate, process, format_output]
)
if __name__ == "__main__":
# Run the workflow - PraisonAI style uses .start()
result = workflow.start("Hello, World!", verbose=True)
print(f"\nFinal output: {result['output']}")
print(f"Steps completed: {len(result['steps'])}")
@@ -0,0 +1,57 @@
"""
Task Callbacks Example
Demonstrates using on_task_start and on_task_complete callbacks
with PraisonAIAgents for workflow monitoring and logging.
"""
from praisonaiagents import Agent, Task, PraisonAIAgents
# Create an agent
researcher = Agent(
name="Researcher",
role="Research Assistant",
goal="Find and summarize information",
llm="gpt-4o-mini"
)
# Create tasks with variables
task1 = Task(
name="research",
description="Research the topic: {{topic}}",
expected_output="A brief summary of the topic",
agent=researcher,
variables={"topic": "artificial intelligence"}
)
task2 = Task(
name="summarize",
description="Create a one-paragraph summary of the research.",
expected_output="A concise summary",
agent=researcher
)
# Define callbacks
def on_start(task, task_id):
"""Called before each task starts."""
print(f"🚀 Starting task: {task.name} (ID: {task_id})")
def on_complete(task, output):
"""Called after each task completes."""
print(f"✅ Completed task: {task.name}")
print(f" Output preview: {str(output)[:100]}...")
if __name__ == "__main__":
# Create agents with callbacks
agents = PraisonAIAgents(
agents=[researcher],
tasks=[task1, task2],
process="workflow",
on_task_start=on_start,
on_task_complete=on_complete,
variables={"global_var": "shared_value"} # Global variables
)
# Run the workflow
result = agents.start()
print("\n🎉 Workflow completed!")
@@ -0,0 +1,48 @@
"""
Workflow Branching Example
Demonstrates conditional branching in workflows where steps can route
to different next steps based on output content.
"""
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
# Create a workflow with branching
workflow = Workflow(
name="Decision Workflow",
description="A workflow that branches based on validation result",
steps=[
WorkflowStep(
name="validate",
action="Check if the number 42 is positive. Reply with 'valid' or 'invalid'.",
next_steps=["success_handler", "error_handler"],
branch_condition={
"valid": ["success_handler"],
"invalid": ["error_handler"]
}
),
WorkflowStep(
name="success_handler",
action="The validation passed! Generate a success message."
),
WorkflowStep(
name="error_handler",
action="The validation failed. Generate an error message."
)
]
)
if __name__ == "__main__":
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Decision Workflow"] = workflow
# Execute - will branch to success_handler since 42 is positive
result = manager.execute(
"Decision Workflow",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
@@ -0,0 +1,63 @@
"""
Workflow Checkpoints Example
Demonstrates saving and resuming workflow execution using checkpoints.
Useful for long-running workflows that may be interrupted.
"""
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
# Create a multi-step workflow
workflow = Workflow(
name="Long Process",
description="A workflow with checkpoints for resumability",
steps=[
WorkflowStep(
name="step1",
action="Initialize the process and prepare data."
),
WorkflowStep(
name="step2",
action="Process the first batch of data."
),
WorkflowStep(
name="step3",
action="Process the second batch of data."
),
WorkflowStep(
name="step4",
action="Finalize and generate report."
)
]
)
if __name__ == "__main__":
manager = WorkflowManager()
manager.workflows["Long Process"] = workflow
# Execute with checkpoint - saves after each step
print("=== Starting workflow with checkpoint ===")
result = manager.execute(
"Long Process",
default_llm="gpt-4o-mini",
checkpoint="my-checkpoint" # Saves progress after each step
)
print(f"Completed {len(result['results'])} steps")
# List available checkpoints
print("\n=== Available checkpoints ===")
checkpoints = manager.list_checkpoints()
for cp in checkpoints:
print(f" {cp['name']}: {cp['completed_steps']} steps completed")
# Resume from checkpoint (if workflow was interrupted)
# result = manager.execute(
# "Long Process",
# default_llm="gpt-4o-mini",
# resume="my-checkpoint" # Resumes from last saved state
# )
# Clean up checkpoint
manager.delete_checkpoint("my-checkpoint")
print("\n✅ Checkpoint deleted")
@@ -0,0 +1,68 @@
"""
Workflow Conditional Execution Example
Demonstrates using should_run to conditionally execute steps
based on input or previous results.
"""
from praisonaiagents import Workflow, WorkflowStep, WorkflowContext, StepResult
# Condition functions - return True to run the step, False to skip
def is_sensitive_content(ctx: WorkflowContext) -> bool:
"""Only run compliance check for sensitive topics."""
sensitive_keywords = ["legal", "medical", "financial", "security"]
return any(keyword in ctx.input.lower() for keyword in sensitive_keywords)
def needs_translation(ctx: WorkflowContext) -> bool:
"""Only translate if content is not in English."""
return ctx.variables.get("language", "en") != "en"
# Handler functions
def process_content(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"Processed: {ctx.input}")
def compliance_check(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"✅ Compliance verified for: {ctx.previous_result}")
def translate(ctx: WorkflowContext) -> StepResult:
lang = ctx.variables.get("language", "unknown")
return StepResult(output=f"🌐 Translated to {lang}: {ctx.previous_result}")
def finalize(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"📄 Final: {ctx.previous_result}")
# Create workflow with conditional steps
workflow = Workflow(
name="Conditional Pipeline",
steps=[
WorkflowStep(name="process", handler=process_content),
WorkflowStep(
name="compliance",
handler=compliance_check,
should_run=is_sensitive_content # Only runs for sensitive content
),
WorkflowStep(
name="translate",
handler=translate,
should_run=needs_translation # Only runs if language != "en"
),
WorkflowStep(name="finalize", handler=finalize)
]
)
if __name__ == "__main__":
# Test 1: Normal content (skips compliance)
print("=== Test 1: Normal content ===")
result = workflow.start("Hello world", verbose=True)
print(f"Output: {result['output']}\n")
# Test 2: Sensitive content (runs compliance)
print("=== Test 2: Legal content ===")
result = workflow.start("Review this legal document", verbose=True)
print(f"Output: {result['output']}\n")
# Test 3: Non-English content (runs translation)
print("=== Test 3: Non-English content ===")
workflow.variables = {"language": "es"}
result = workflow.start("Hola mundo", verbose=True)
print(f"Output: {result['output']}")
@@ -0,0 +1,69 @@
"""
Workflow Early Stop Example
Demonstrates how to stop a workflow early using custom handler
functions that return StepResult with stop_workflow=True.
"""
from praisonaiagents.memory.workflows import (
WorkflowManager, WorkflowStep, Workflow,
WorkflowContext, StepResult
)
# Custom validator that can stop the workflow
def validate_data(context: WorkflowContext) -> StepResult:
"""Check if data is valid and stop workflow if not."""
data = context.variables.get("data", {})
if data.get("value", 0) < 0:
return StepResult(
output="❌ Validation failed: negative value detected. Stopping workflow.",
stop_workflow=True # This stops the entire workflow
)
return StepResult(
output="✅ Validation passed: data is valid.",
stop_workflow=False # Continue to next step
)
# Create workflow with early stop capability
workflow = Workflow(
name="Data Processing",
description="Process data with validation gate",
variables={
"data": {"value": -5} # Invalid data - will trigger early stop
},
steps=[
WorkflowStep(
name="validate",
handler=validate_data # Custom function
),
WorkflowStep(
name="process",
action="Process the validated data." # Won't run if validation fails
),
WorkflowStep(
name="report",
action="Generate final report." # Won't run if validation fails
)
]
)
if __name__ == "__main__":
manager = WorkflowManager()
manager.workflows["Data Processing"] = workflow
# Test with invalid data (will stop early)
print("=== Testing with invalid data ===")
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['output']}")
# Test with valid data (will complete)
print("\n=== Testing with valid data ===")
workflow.variables["data"] = {"value": 42}
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
@@ -0,0 +1,55 @@
"""
Workflow Loop with CSV Example
Demonstrates iterating over a CSV file, processing each row.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop
import tempfile
import os
# Create sample CSV file
csv_content = """name,email,task
Alice,[email protected],Write documentation
Bob,[email protected],Review pull requests
Charlie,[email protected],Fix critical bug
Diana,[email protected],Deploy to production"""
# Processor for each CSV row
def process_task(ctx: WorkflowContext) -> StepResult:
"""Process a single task from CSV."""
item = ctx.variables.get("item", {})
name = item.get("name", "Unknown")
task = item.get("task", "No task")
index = ctx.variables.get("loop_index", 0)
return StepResult(
output=f"[{index+1}] {name}: {task} ✅",
variables={"last_processed": name}
)
if __name__ == "__main__":
# Create temp CSV file
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
csv_path = f.name
try:
print("=== Testing Loop with CSV ===\n")
print(f"CSV file: {csv_path}\n")
# Create workflow
workflow = Workflow(
name="CSV Task Processor",
steps=[loop(process_task, from_csv=csv_path)]
)
result = workflow.start("Process all tasks", verbose=True)
print(f"\nAll outputs:\n{result['output']}")
print(f"\nProcessed {len(result['variables']['loop_outputs'])} items")
print(f"Last processed: {result['variables'].get('last_processed')}")
finally:
os.unlink(csv_path)
@@ -0,0 +1,52 @@
"""
Workflow Loop with List Example
Demonstrates iterating over a list of items, processing each one.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop
# Sample data
fruits = ["apple", "banana", "cherry", "date", "elderberry"]
# Processor for each item
def process_fruit(ctx: WorkflowContext) -> StepResult:
"""Process a single fruit from the list."""
fruit = ctx.variables.get("item", "unknown")
index = ctx.variables.get("loop_index", 0)
# Simulate processing
result = f"🍎 [{index + 1}] {fruit.upper()} - processed successfully"
return StepResult(
output=result,
variables={"last_fruit": fruit}
)
# Aggregator to summarize results
def summarize(ctx: WorkflowContext) -> StepResult:
"""Summarize all processed fruits."""
outputs = ctx.variables.get("loop_outputs", [])
summary = f"📊 Processed {len(outputs)} fruits:\n"
summary += "\n".join(f" {o}" for o in outputs)
return StepResult(output=summary)
# Create workflow
workflow = Workflow(
name="Fruit Processor",
steps=[
loop(process_fruit, over="fruits"),
summarize
],
variables={"fruits": fruits}
)
if __name__ == "__main__":
print("=== Testing Loop with List ===\n")
result = workflow.start("Process all fruits", verbose=True)
print(f"\nFinal Output:\n{result['output']}")
print(f"\nLoop outputs count: {len(result['variables']['loop_outputs'])}")
print(f"Last processed: {result['variables'].get('last_fruit')}")
@@ -0,0 +1,46 @@
"""
Workflow Loops Example
Demonstrates iterating over a list of items in a workflow step.
Each item is processed individually with the loop variable.
"""
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
# Create a workflow with loop
workflow = Workflow(
name="Process Items",
description="Process a list of items using loop",
variables={
"items": ["apple", "banana", "cherry"] # List to iterate over
},
steps=[
WorkflowStep(
name="process_each",
action="Describe the fruit: {{item}}",
loop_over="items", # Variable name containing the list
loop_var="item" # Variable name for current item
),
WorkflowStep(
name="summarize",
action="Summarize all the fruits that were processed."
)
]
)
if __name__ == "__main__":
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Process Items"] = workflow
# Execute - will process each fruit in the list
result = manager.execute(
"Process Items",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
if isinstance(step_result.get("output"), list):
print(f" Loop processed {len(step_result['output'])} items")
@@ -0,0 +1,68 @@
"""
Mixed Steps Workflow Example
Demonstrates combining different step types in a single workflow:
- Agent instances
- Handler functions
- WorkflowStep with action strings
"""
from praisonaiagents import Workflow, WorkflowStep, WorkflowContext, StepResult, Agent
# Create an agent
analyzer = Agent(
name="Analyzer",
role="Data Analyst",
goal="Analyze and summarize data",
llm="gpt-4o-mini"
)
# Custom handler functions
def validate_input(ctx: WorkflowContext) -> StepResult:
"""Validate the input before processing."""
if len(ctx.input) < 5:
return StepResult(
output="Input too short. Minimum 5 characters required.",
stop_workflow=True
)
return StepResult(output=f"✅ Valid input: {ctx.input}")
def format_output(ctx: WorkflowContext) -> StepResult:
"""Format the final output."""
return StepResult(
output=f"📊 Report:\n{ctx.previous_result}",
variables={"report_generated": True}
)
# Create workflow with mixed step types
workflow = Workflow(
name="Mixed Pipeline",
steps=[
# Step 1: Function - validates input
validate_input,
# Step 2: Agent - analyzes content
analyzer,
# Step 3: WorkflowStep with action string
WorkflowStep(
name="enhance",
action="Add a conclusion to this analysis: {{previous_output}}"
),
# Step 4: Function - formats output
format_output
],
default_llm="gpt-4o-mini" # Used for action-based steps
)
if __name__ == "__main__":
# Test with valid input
print("=== Running mixed workflow ===")
result = workflow.start(
"Analyze the trends in AI adoption for 2024",
verbose=True
)
print(f"\nFinal output:\n{result['output']}")
print(f"\nVariables: {result['variables']}")
@@ -0,0 +1,53 @@
"""
Workflow Parallel Execution Example
Demonstrates running multiple steps concurrently and
combining their results.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import parallel
import time
# Parallel workers - each does independent work
def research_market(ctx: WorkflowContext) -> StepResult:
"""Research market trends."""
time.sleep(0.1) # Simulate work
return StepResult(output="📊 Market: Growth expected 15% YoY")
def research_competitors(ctx: WorkflowContext) -> StepResult:
"""Research competitor analysis."""
time.sleep(0.1) # Simulate work
return StepResult(output="🏢 Competitors: 3 major players identified")
def research_customers(ctx: WorkflowContext) -> StepResult:
"""Research customer feedback."""
time.sleep(0.1) # Simulate work
return StepResult(output="👥 Customers: 85% satisfaction rate")
# Aggregator - combines parallel results
def summarize_research(ctx: WorkflowContext) -> StepResult:
"""Summarize all research findings."""
outputs = ctx.variables.get("parallel_outputs", [])
summary = "📋 RESEARCH SUMMARY:\n" + "\n".join(f" • {o}" for o in outputs)
return StepResult(output=summary)
# Create workflow with parallel execution
workflow = Workflow(
name="Parallel Research",
steps=[
parallel([research_market, research_competitors, research_customers]),
summarize_research
]
)
if __name__ == "__main__":
print("=== Testing Parallel Workflow ===\n")
start_time = time.time()
result = workflow.start("Analyze the business landscape", verbose=True)
elapsed = time.time() - start_time
print(f"\nFinal Output:\n{result['output']}")
print(f"\nCompleted in {elapsed:.2f}s (parallel execution)")
print(f"Individual outputs: {len(result['variables']['parallel_outputs'])}")
@@ -0,0 +1,69 @@
"""
Workflow Repeat (Evaluator-Optimizer) Example
Demonstrates repeating a step until a condition is met.
This is useful for iterative improvement patterns.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import repeat
# Simulated content generator that improves each iteration
class ContentGenerator:
def __init__(self):
self.iteration = 0
self.content = []
def generate(self, ctx: WorkflowContext) -> StepResult:
"""Generate content, adding more each iteration."""
self.iteration += 1
# Add new content each iteration
new_items = [
f"Point {self.iteration}.1: Key insight about the topic",
f"Point {self.iteration}.2: Supporting evidence"
]
self.content.extend(new_items)
output = f"Iteration {self.iteration}: Generated {len(self.content)} points total"
return StepResult(
output=output,
variables={
"total_points": len(self.content),
"content": self.content.copy()
}
)
# Condition: stop when we have enough content
def has_enough_content(ctx: WorkflowContext) -> bool:
"""Check if we have generated enough content."""
total = ctx.variables.get("total_points", 0)
return total >= 6 # Stop when we have 6+ points
# Create generator instance
generator = ContentGenerator()
# Create workflow with repeat pattern
workflow = Workflow(
name="Content Generator",
steps=[
repeat(
generator.generate,
until=has_enough_content,
max_iterations=10
)
]
)
if __name__ == "__main__":
print("=== Testing Repeat (Evaluator-Optimizer) Pattern ===\n")
result = workflow.start("Generate comprehensive content", verbose=True)
print(f"\nFinal output: {result['output']}")
print(f"Total iterations: {result['variables']['repeat_iterations']}")
print(f"Total points generated: {result['variables']['total_points']}")
print("\nGenerated content:")
for point in result['variables']['content']:
print(f" • {point}")
@@ -0,0 +1,61 @@
"""
Workflow Routing Example
Demonstrates decision-based routing where the workflow
takes different paths based on the output of a decision step.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import route
# Decision maker - determines which route to take
def classify_request(ctx: WorkflowContext) -> StepResult:
"""Classify the input and decide the route."""
input_lower = ctx.input.lower()
if "urgent" in input_lower or "emergency" in input_lower:
return StepResult(output="priority: high")
elif "question" in input_lower or "help" in input_lower:
return StepResult(output="priority: support")
else:
return StepResult(output="priority: normal")
# Route handlers
def handle_high_priority(ctx: WorkflowContext) -> StepResult:
return StepResult(output="🚨 HIGH PRIORITY: Escalating to senior team immediately!")
def handle_support(ctx: WorkflowContext) -> StepResult:
return StepResult(output="💬 SUPPORT: Routing to help desk for assistance.")
def handle_normal(ctx: WorkflowContext) -> StepResult:
return StepResult(output="📋 NORMAL: Added to standard processing queue.")
# Create workflow with routing
workflow = Workflow(
name="Request Router",
steps=[
classify_request,
route({
"high": [handle_high_priority],
"support": [handle_support],
"normal": [handle_normal],
"default": [handle_normal]
})
]
)
if __name__ == "__main__":
# Test different inputs
print("=== Testing Routing Workflow ===\n")
# Test 1: Urgent request
result = workflow.start("This is an URGENT matter!", verbose=True)
print(f"Result: {result['output']}\n")
# Test 2: Support request
result = workflow.start("I have a question about my account", verbose=True)
print(f"Result: {result['output']}\n")
# Test 3: Normal request
result = workflow.start("Please process my order", verbose=True)
print(f"Result: {result['output']}")
@@ -0,0 +1,45 @@
"""
Workflow with Agents Example
Demonstrates using Agent instances directly as workflow steps.
Agents are automatically wrapped and executed in sequence.
"""
from praisonaiagents import Workflow, Agent
# Create agents
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Write engaging content",
llm="gpt-4o-mini"
)
editor = Agent(
name="Editor",
role="Content Editor",
goal="Polish and improve content",
llm="gpt-4o-mini"
)
# Create workflow with agents as steps
workflow = Workflow(
name="Content Pipeline",
steps=[researcher, writer, editor]
)
if __name__ == "__main__":
# Run the workflow
result = workflow.start(
"Write a short paragraph about artificial intelligence",
verbose=True
)
print(f"\nFinal output:\n{result['output']}")
@@ -0,0 +1,287 @@
"""
Fast Context Usage Examples.
This file demonstrates how to use the Fast Context feature for rapid code search.
Fast Context is inspired by Windsurf's SWE-grep approach:
- Parallel tool execution (up to 8 concurrent calls)
- Limited turns (max 4) for fast response
- Lightweight model for search operations
- Result caching for repeated queries
"""
# Example 1: Basic FastContext Usage
# ==================================
def example_basic_search():
"""Basic search using FastContext."""
from praisonaiagents.context.fast import FastContext
# Create FastContext instance
fc = FastContext(workspace_path=".")
# Search for a pattern
result = fc.search("def authenticate")
print(f"Found {result.total_files} files")
print(f"Search time: {result.search_time_ms}ms")
# Print results
for file_match in result.files[:5]:
print(f" - {file_match.path}")
for lr in file_match.line_ranges:
print(f" Lines {lr.start}-{lr.end}")
# Example 2: Search with Patterns
# ===============================
def example_search_with_patterns():
"""Search with include/exclude patterns."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=".")
# Search only in Python files
result = fc.search(
"class Agent",
include_patterns=["**/*.py"],
exclude_patterns=["**/tests/**", "**/test_*.py"]
)
print(f"Found {result.total_files} files matching 'class Agent'")
for f in result.files:
print(f" - {f.path}")
# Example 3: Get Context for Agent
# ================================
def example_get_context_for_agent():
"""Get formatted context string for an agent."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=".")
# Get context formatted for injection into an agent
context = fc.get_context_for_agent(
"authentication handlers",
max_files=5,
max_lines_per_file=50
)
print("Context for agent:")
print(context[:500] + "..." if len(context) > 500 else context)
# Example 4: Agent with FastContext Integration
# =============================================
def example_agent_with_fast_context():
"""Use FastContext with an Agent."""
from praisonaiagents import Agent
# Create agent with FastContext enabled
agent = Agent(
name="CodeAssistant",
instructions="You are a helpful code assistant.",
fast_context=True,
fast_context_path=".", # Current directory
fast_context_model="gpt-4o-mini",
fast_context_max_turns=4,
fast_context_parallelism=8
)
# Use delegate_to_fast_context for code search
context = agent.delegate_to_fast_context("find authentication code")
if context:
print("Found relevant code context:")
print(context[:300] + "...")
else:
print("No relevant code found")
# Example 5: File Indexer
# =======================
def example_file_indexer():
"""Use FileIndexer for fast file lookups."""
from praisonaiagents.context.fast.indexer import FileIndexer
# Create and run indexer
indexer = FileIndexer(workspace_path=".")
count = indexer.index()
print(f"Indexed {count} files")
# Find files by pattern
py_files = indexer.find_by_pattern("**/*.py")
print(f"Found {len(py_files)} Python files")
# Find files by extension
js_files = indexer.find_by_extension(".js")
print(f"Found {len(js_files)} JavaScript files")
# Get stats
stats = indexer.get_stats()
print(f"Total size: {stats['total_size_mb']} MB")
# Example 6: Symbol Indexer
# =========================
def example_symbol_indexer():
"""Use SymbolIndexer to find code symbols."""
from praisonaiagents.context.fast.indexer import SymbolIndexer, SymbolType
# Create and run indexer
indexer = SymbolIndexer(workspace_path=".")
count = indexer.index()
print(f"Indexed {count} symbols")
# Find symbols by name
agent_symbols = indexer.find_by_name("Agent")
print(f"Found {len(agent_symbols)} symbols containing 'Agent'")
# Find all classes
classes = indexer.find_by_type(SymbolType.CLASS)
print(f"Found {len(classes)} classes")
# Get stats
stats = indexer.get_stats()
print(f"Symbols by type: {stats['by_type']}")
# Example 7: Context Injector
# ===========================
def example_context_injector():
"""Use ContextInjector to format results for agents."""
from praisonaiagents.context.fast import FastContext
from praisonaiagents.context.fast.context_injector import (
ContextInjector,
InjectionConfig
)
# Search for code
fc = FastContext(workspace_path=".")
result = fc.search("def main")
# Configure injection
config = InjectionConfig(
max_tokens=2000,
max_files=5,
format_style="markdown", # or "xml", "plain"
prioritize_precision=True
)
# Inject into prompts
injector = ContextInjector(config)
formatted = injector.format_context(result)
print("Formatted context:")
print(formatted[:500] + "..." if len(formatted) > 500 else formatted)
# Example 8: Environment Variable Configuration
# =============================================
def example_env_config():
"""Configure FastContext via environment variables."""
import os
# Set environment variables (usually done in shell or .env file)
os.environ["FAST_CONTEXT_MODEL"] = "gpt-4o-mini"
os.environ["FAST_CONTEXT_MAX_TURNS"] = "4"
os.environ["FAST_CONTEXT_PARALLELISM"] = "8"
os.environ["FAST_CONTEXT_TIMEOUT"] = "30.0"
os.environ["FAST_CONTEXT_CACHE"] = "true"
os.environ["FAST_CONTEXT_CACHE_TTL"] = "300"
from praisonaiagents.context.fast import FastContext
# FastContext will use environment variables as defaults
fc = FastContext(workspace_path=".")
print(f"Model: {fc.model}")
print(f"Max turns: {fc.max_turns}")
print(f"Max parallel: {fc.max_parallel}")
print(f"Cache enabled: {fc.cache_enabled}")
# Example 9: Caching
# ==================
def example_caching():
"""Demonstrate result caching."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(
workspace_path=".",
cache_enabled=True,
cache_ttl=300 # 5 minutes
)
# First search - not cached
result1 = fc.search("def main")
print(f"First search: from_cache={result1.from_cache}, time={result1.search_time_ms}ms")
# Second search - cached
result2 = fc.search("def main")
print(f"Second search: from_cache={result2.from_cache}, time={result2.search_time_ms}ms")
# Clear cache
fc.clear_cache()
# Third search - not cached
result3 = fc.search("def main")
print(f"Third search: from_cache={result3.from_cache}, time={result3.search_time_ms}ms")
# Example 10: Context Manager
# ===========================
def example_context_manager():
"""Use FastContext as a context manager."""
from praisonaiagents.context.fast import FastContext
with FastContext(workspace_path=".") as fc:
result = fc.search("import")
print(f"Found {result.total_files} files with imports")
# Resources are automatically cleaned up
if __name__ == "__main__":
print("=" * 60)
print("Fast Context Examples")
print("=" * 60)
print("\n1. Basic Search")
print("-" * 40)
example_basic_search()
print("\n2. Search with Patterns")
print("-" * 40)
example_search_with_patterns()
print("\n3. Get Context for Agent")
print("-" * 40)
example_get_context_for_agent()
print("\n5. File Indexer")
print("-" * 40)
example_file_indexer()
print("\n9. Caching")
print("-" * 40)
example_caching()
print("\n10. Context Manager")
print("-" * 40)
example_context_manager()
print("\n" + "=" * 60)
print("All examples completed!")
@@ -54,8 +54,32 @@ except ImportError:
MCP = None
from .session import Session
from .memory.memory import Memory
# Workflows - import from dedicated workflows module
from .workflows import (
Workflow, WorkflowStep, WorkflowContext, StepResult,
Route, Parallel, Loop, Repeat,
route, parallel, loop, repeat,
Pipeline # Alias for Workflow
)
from .guardrails import GuardrailResult, LLMGuardrail
# Fast Context support (lazy loaded to avoid performance impact)
def __getattr__(name):
"""Lazy load FastContext to avoid impacting package load time."""
if name == "FastContext":
from praisonaiagents.context.fast import FastContext
return FastContext
elif name == "FastContextResult":
from praisonaiagents.context.fast import FastContextResult
return FastContextResult
elif name == "FileMatch":
from praisonaiagents.context.fast import FileMatch
return FileMatch
elif name == "LineRange":
from praisonaiagents.context.fast import LineRange
return LineRange
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Planning mode support
try:
from .planning import (
@@ -273,3 +297,11 @@ if _mcp_available:
if FlowDisplay is not None:
__all__.extend(['FlowDisplay', 'track_workflow'])
# Add FastContext exports (lazy loaded)
__all__.extend([
'FastContext',
'FastContextResult',
'FileMatch',
'LineRange'
])
@@ -242,7 +242,15 @@ class Agent:
plan_mode: bool = False,
planning: bool = False,
planning_tools: Optional[List[Any]] = None,
planning_reasoning: bool = False
planning_reasoning: bool = False,
fast_context: bool = False,
fast_context_path: Optional[str] = None,
fast_context_model: str = "gpt-4o-mini",
fast_context_max_turns: int = 4,
fast_context_parallelism: int = 8,
fast_context_timeout: float = 30.0,
history_in_context: Optional[int] = None,
auto_save: Optional[str] = None
):
"""Initialize an Agent instance.
@@ -538,6 +546,10 @@ Your Goal: {self.goal}
self.prompt_caching = prompt_caching
self.claude_memory = claude_memory
# Session management
self.history_in_context = history_in_context # Number of past sessions to include
self.auto_save = auto_save # Session name for auto-saving
# Initialize rules manager for persistent context (like Cursor/Windsurf)
self._rules_manager = None
self._init_rules_manager()
@@ -585,6 +597,15 @@ Your Goal: {self.goal}
self._knowledge_config = knowledge_config
self.knowledge = None # Will be initialized on first use
# Fast Context configuration (lazy loaded)
self.fast_context_enabled = fast_context
self.fast_context_path = fast_context_path or os.getcwd()
self.fast_context_model = fast_context_model
self.fast_context_max_turns = fast_context_max_turns
self.fast_context_parallelism = fast_context_parallelism
self.fast_context_timeout = fast_context_timeout
self._fast_context_instance = None # Lazy loaded
@property
def console(self):
"""Lazily initialize Rich Console only when needed."""
@@ -713,6 +734,57 @@ Your Goal: {self.goal}
return supports_prompt_caching(model_name)
@property
def fast_context(self):
"""Lazily initialize FastContext instance when needed.
Returns:
FastContext instance or None if not enabled
"""
if not self.fast_context_enabled:
return None
if self._fast_context_instance is None:
try:
from ..context.fast import FastContext
self._fast_context_instance = FastContext(
workspace_path=self.fast_context_path,
model=self.fast_context_model,
max_turns=self.fast_context_max_turns,
max_parallel=self.fast_context_parallelism,
timeout=self.fast_context_timeout,
verbose=self.verbose
)
except ImportError:
logging.warning("FastContext not available")
return None
return self._fast_context_instance
def delegate_to_fast_context(self, query: str) -> Optional[str]:
"""Delegate a code search query to FastContext subagent.
This method uses the FastContext subagent to rapidly search
the codebase and return relevant context.
Args:
query: Natural language search query
Returns:
Formatted context string or None if FastContext not available
"""
if not self.fast_context_enabled or self.fast_context is None:
return None
try:
result = self.fast_context.search(query)
if result.total_files > 0:
return self.fast_context.get_context_for_agent(query)
return None
except Exception as e:
logging.warning(f"FastContext search failed: {e}")
return None
def _init_rules_manager(self):
"""
Initialize RulesManager for persistent rules/instructions.
@@ -2561,10 +2633,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Execute the step
result = self.chat(step_prompt, **kwargs)
results.append(result)
results.append({"step": i + 1, "description": step.description, "result": result})
# Update context for next step
context += f"\n\nStep {i + 1} result: {result[:500] if result else 'No result'}"
# Update context for next step (use full result, not truncated)
context += f"\n\nStep {i + 1} result: {result if result else 'No result'}"
console.print(f" [green]✅ Completed[/green]")
@@ -2572,26 +2644,116 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
console.print(f"[dim]Progress: [{'█' * bar_length}] 100%[/dim]")
console.print(f"Completed {len(plan.steps)}/{len(plan.steps)} steps!\n")
# Return the final result
return results[-1] if results else None
# Compile all results into a comprehensive final output
if len(results) > 1:
# Create a compilation prompt
all_results_text = "\n\n".join([
f"## Step {r['step']}: {r['description']}\n\n{r['result']}"
for r in results
])
compilation_prompt = f"""You are tasked with compiling a comprehensive, detailed report from the following research steps.
IMPORTANT: Write a DETAILED and COMPREHENSIVE report. Do NOT summarize or compress the information.
Include ALL relevant details, data, statistics, and findings from each step.
Organize the information logically with clear sections and subsections.
## Research Results to Compile:
{all_results_text}
## Instructions:
1. Combine all the information into a single, well-organized document
2. Preserve ALL details, numbers, statistics, and specific findings
3. Use clear headings and subheadings
4. Do not omit any important information
5. Make it comprehensive and detailed
Write the complete compiled report:"""
console.print("\n[bold blue]📝 COMPILING FINAL REPORT[/bold blue]")
console.print("[dim]Creating comprehensive output from all steps...[/dim]\n")
final_result = self.chat(compilation_prompt, **kwargs)
return final_result
# Return the single result if only one step
return results[0]["result"] if results else None
def start(self, prompt: str, **kwargs):
"""Start the agent with a prompt. This is a convenience method that wraps chat()."""
# Load history from past sessions if history_in_context is set
self._load_history_context()
# Check if planning mode is enabled
if self.planning:
return self._start_with_planning(prompt, **kwargs)
# Check if streaming is enabled (either from kwargs or agent's stream attribute)
stream_enabled = kwargs.get('stream', getattr(self, 'stream', False))
if stream_enabled:
result = self._start_with_planning(prompt, **kwargs)
elif kwargs.get('stream', getattr(self, 'stream', False)):
# Return a generator for streaming response
return self._start_stream(prompt, **kwargs)
result = self._start_stream(prompt, **kwargs)
else:
# Return regular chat response for backward compatibility
# Explicitly pass the resolved stream parameter to avoid chat() method default
kwargs['stream'] = stream_enabled
return self.chat(prompt, **kwargs)
kwargs['stream'] = False
result = self.chat(prompt, **kwargs)
# Auto-save session if enabled
self._auto_save_session()
return result
def _load_history_context(self):
"""Load history from past sessions into context if history_in_context is set."""
if not self.history_in_context or not self._memory_instance:
return
try:
sessions = self._memory_instance.list_sessions()
if not sessions:
return
# Get the last N sessions
sessions_to_load = sessions[:self.history_in_context]
for session_info in reversed(sessions_to_load):
try:
session_data = self._memory_instance.resume_session(session_info["name"])
history = session_data.get("conversation_history", [])
# Add history to chat_history with a marker
for msg in history:
if msg not in self.chat_history:
# Mark as from history to avoid duplication
msg_copy = msg.copy()
msg_copy["_from_history"] = True
self.chat_history.append(msg_copy)
except Exception:
continue
if sessions_to_load:
logging.debug(f"Loaded history from {len(sessions_to_load)} past session(s)")
except Exception as e:
logging.debug(f"Error loading history context: {e}")
def _auto_save_session(self):
"""Auto-save session if auto_save is enabled."""
if not self.auto_save or not self._memory_instance:
return
try:
# Filter out history markers before saving
clean_history = [
{k: v for k, v in msg.items() if k != "_from_history"}
for msg in self.chat_history
]
self._memory_instance.save_session(
name=self.auto_save,
conversation_history=clean_history,
metadata={"agent_name": self.name, "user_id": self.user_id}
)
logging.debug(f"Auto-saved session: {self.auto_save}")
except Exception as e:
logging.debug(f"Error auto-saving session: {e}")
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Stream generator for real-time response chunks."""
@@ -258,11 +258,24 @@ Expanded prompt:"""
prompt_lower = prompt.lower().strip()
return any(prompt_lower.startswith(verb) for verb in task_verbs)
def _is_technical_task(self, prompt: str) -> bool:
"""Check if prompt is a technical/coding task."""
technical_indicators = [
"python", "javascript", "java", "code", "function", "class",
"api", "database", "sql", "html", "css", "react", "node",
"algorithm", "debug", "fix", "error", "bug", "test",
"deploy", "server", "docker", "kubernetes", "aws", "azure",
"typescript", "rust", "go", "c++", "c#", "ruby", "php",
"script", "bash", "shell", "terminal", "command", "cli"
]
prompt_lower = prompt.lower()
return any(ind in prompt_lower for ind in technical_indicators)
def _is_creative_task(self, prompt: str) -> bool:
"""Check if prompt is a creative task."""
"""Check if prompt is a creative task (non-technical)."""
creative_indicators = [
"poem", "story", "script", "song", "art", "creative",
"fiction", "novel", "essay", "blog", "article", "write"
"poem", "story", "song", "art", "creative",
"fiction", "novel", "essay", "blog", "article"
]
prompt_lower = prompt.lower()
return any(ind in prompt_lower for ind in creative_indicators)
@@ -273,6 +286,10 @@ Expanded prompt:"""
if self._is_short_prompt(prompt, threshold=5):
return ExpandStrategy.DETAILED
# Technical/coding tasks get structured expansion (check before creative)
if self._is_technical_task(prompt):
return ExpandStrategy.STRUCTURED
# Creative tasks benefit from creative expansion
if self._is_creative_task(prompt):
return ExpandStrategy.CREATIVE
@@ -330,6 +347,37 @@ Expanded prompt:"""
current_date=current_date
)
# Add tool usage instructions if tools are available
if self.tools:
# Build dynamic tool descriptions from docstrings
tool_descriptions = []
for tool in self.tools:
tool_name = getattr(tool, '__name__', str(tool))
tool_doc = getattr(tool, '__doc__', '')
# Get first line of docstring as description
if tool_doc:
first_line = tool_doc.strip().split('\n')[0].strip()
tool_descriptions.append(f"- {tool_name}: {first_line}")
else:
tool_descriptions.append(f"- {tool_name}")
tools_list = '\n'.join(tool_descriptions)
tool_instruction = f"""
IMPORTANT: You have the following tools available:
{tools_list}
Before expanding this prompt, FIRST use the available tools to gather relevant current information about the topic. This will help you create a more informed and accurate expanded prompt.
Steps:
1. Use the appropriate tool(s) to gather relevant information about the topic
2. Use the gathered information to inform your expansion
3. Create an expanded prompt that incorporates the latest context
"""
expansion_prompt = tool_instruction + expansion_prompt
# Call the agent
expanded = self._call_agent(expansion_prompt).strip()
@@ -2,7 +2,7 @@ import os
import time
import json
import logging
from typing import Any, Dict, Optional, List
from typing import Any, Dict, Optional, List, Callable
from pydantic import BaseModel
from rich.text import Text
from rich.panel import Panel
@@ -127,7 +127,7 @@ def process_task_context(context_item, verbose=0, user_id=None):
return str(context_item) # Fallback for unknown types
class PraisonAIAgents:
def __init__(self, agents, tasks=None, verbose=0, completion_checker=None, max_retries=5, process="sequential", manager_llm=None, memory=False, memory_config=None, embedder=None, user_id=None, max_iter=10, stream=True, name: Optional[str] = None, planning: bool = False, planning_llm: Optional[str] = None, auto_approve_plan: bool = False, planning_tools: Optional[List] = None, planning_reasoning: bool = False):
def __init__(self, agents, tasks=None, verbose=0, completion_checker=None, max_retries=5, process="sequential", manager_llm=None, memory=False, memory_config=None, embedder=None, user_id=None, max_iter=10, stream=True, name: Optional[str] = None, planning: bool = False, planning_llm: Optional[str] = None, auto_approve_plan: bool = False, planning_tools: Optional[List] = None, planning_reasoning: bool = False, on_task_start: Optional[Callable] = None, on_task_complete: Optional[Callable] = None, variables: Optional[Dict[str, Any]] = None):
# Add check at the start if memory is requested
if memory:
try:
@@ -162,6 +162,11 @@ class PraisonAIAgents:
self.stream = stream
self.name = name # Store the name for the Agents collection
# Callbacks for workflow execution
self.on_task_start = on_task_start # Called before each task
self.on_task_complete = on_task_complete # Called after each task
self.variables = variables if variables else {} # Global variables for substitution
# Check for manager_llm in environment variable if not provided
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
@@ -661,13 +666,24 @@ Context:
executor_agent = task.agent
# Create agent from agent_config if provided and no agent assigned
if executor_agent is None and getattr(task, 'agent_config', None):
executor_agent = self._create_agent_from_config(task.agent_config)
task.agent = executor_agent
# Set current agent for token tracking
llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
if llm and hasattr(llm, 'set_current_agent'):
llm.set_current_agent(executor_agent.name)
# Substitute variables in task description if provided
task_description = task.description
if getattr(task, 'variables', None):
for key, value in task.variables.items():
task_description = task_description.replace(f"{{{{{key}}}}}", str(value))
task_prompt = f"""
You need to do the following task: {task.description}.
You need to do the following task: {task_description}.
Expected Output: {task.expected_output}.
"""
if task.context:
@@ -825,6 +841,17 @@ Context:
logger.info(f"Task with ID {task_id} is already completed")
return
# Call on_task_start callback if provided
if self.on_task_start:
try:
self.on_task_start(task, task_id)
except Exception as e:
logger.error(f"Error in on_task_start callback: {e}")
# Apply global variables to task if not already set
if self.variables and not getattr(task, 'variables', None):
task.variables = self.variables
retries = 0
while task.status != "completed" and retries < self.max_retries:
logger.debug(f"Attempt {retries+1} for task {task_id}")
@@ -857,6 +884,14 @@ Context:
logger.exception(e)
self.save_output_to_file(task, task_output)
# Call on_task_complete callback if provided
if self.on_task_complete:
try:
self.on_task_complete(task, task_output)
except Exception as e:
logger.error(f"Error in on_task_complete callback: {e}")
if self.verbose >= 1:
logger.info(f"Task {task_id} completed successfully.")
else:
@@ -1561,6 +1596,29 @@ Context:
"""Get the current todo list."""
return self._todo_list
def _create_agent_from_config(self, agent_config: dict) -> 'Agent':
"""
Create an Agent from a configuration dictionary.
Args:
agent_config: Dict with keys like 'name', 'role', 'goal', 'backstory', 'llm', 'tools'
Returns:
Agent instance
"""
from ..agent.agent import Agent
return Agent(
name=agent_config.get('name', 'TaskAgent'),
role=agent_config.get('role'),
goal=agent_config.get('goal'),
backstory=agent_config.get('backstory'),
llm=agent_config.get('llm'),
tools=agent_config.get('tools'),
verbose=self.verbose >= 1,
memory=self.memory
)
def _get_planning_agent(self):
"""Lazy load PlanningAgent."""
if self._planning_agent is None and self.planning:
@@ -0,0 +1,30 @@
"""
Context module for PraisonAI Agents.
This module provides context retrieval capabilities including:
- Fast Context: Rapid parallel code search subagent
"""
# Lazy imports to avoid impacting package load time
def __getattr__(name):
"""Lazy import to avoid impacting package performance."""
if name == "FastContext":
from praisonaiagents.context.fast.fast_context import FastContext
return FastContext
elif name == "FastContextResult":
from praisonaiagents.context.fast.result import FastContextResult
return FastContextResult
elif name == "FileMatch":
from praisonaiagents.context.fast.result import FileMatch
return FileMatch
elif name == "LineRange":
from praisonaiagents.context.fast.result import LineRange
return LineRange
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"FastContext",
"FastContextResult",
"FileMatch",
"LineRange",
]
@@ -0,0 +1,40 @@
"""
Fast Context module for rapid parallel code search.
This module implements a specialized subagent that retrieves relevant code
from a codebase using parallel tool calls, similar to Windsurf's Fast Context.
Key features:
- Parallel tool execution (up to 8 concurrent calls)
- Limited serial turns (max 4) for fast response
- Restricted tool set (grep, glob, read) for safety
- Returns files + line ranges (no summarization)
"""
# Lazy imports to avoid impacting package load time
def __getattr__(name):
"""Lazy import to avoid impacting package performance."""
if name == "FastContext":
from praisonaiagents.context.fast.fast_context import FastContext
return FastContext
elif name == "FastContextResult":
from praisonaiagents.context.fast.result import FastContextResult
return FastContextResult
elif name == "FileMatch":
from praisonaiagents.context.fast.result import FileMatch
return FileMatch
elif name == "LineRange":
from praisonaiagents.context.fast.result import LineRange
return LineRange
elif name == "FastContextAgent":
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
return FastContextAgent
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"FastContext",
"FastContextResult",
"FileMatch",
"LineRange",
"FastContextAgent",
]
@@ -0,0 +1,383 @@
"""
Context Injector for Fast Context.
Injects FastContext results into the main agent with:
- Token budget management
- Context formatting for different models
- Precision over recall (avoid context pollution)
"""
import logging
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
logger = logging.getLogger(__name__)
@dataclass
class InjectionConfig:
"""Configuration for context injection.
Attributes:
max_tokens: Maximum tokens for injected context
max_files: Maximum files to include
max_lines_per_file: Maximum lines per file
include_line_numbers: Whether to include line numbers
include_file_content: Whether to include file content
format_style: Format style ('markdown', 'xml', 'plain')
prioritize_precision: If True, include fewer but more relevant results
"""
max_tokens: int = 4000
max_files: int = 10
max_lines_per_file: int = 100
include_line_numbers: bool = True
include_file_content: bool = True
format_style: str = "markdown"
prioritize_precision: bool = True
# Approximate tokens per character (conservative estimate)
chars_per_token: float = 4.0
class ContextInjector:
"""Injects FastContext results into agent context.
Handles:
- Token budget management
- Result formatting
- Context pollution prevention
"""
def __init__(self, config: Optional[InjectionConfig] = None):
"""Initialize context injector.
Args:
config: Injection configuration
"""
self.config = config or InjectionConfig()
def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text.
Args:
text: Text to estimate
Returns:
Estimated token count
"""
return int(len(text) / self.config.chars_per_token)
def inject(
self,
result: FastContextResult,
system_prompt: Optional[str] = None,
user_message: Optional[str] = None
) -> Dict[str, str]:
"""Inject FastContext results into agent context.
Args:
result: FastContext search result
system_prompt: Original system prompt
user_message: Original user message
Returns:
Dict with 'system_prompt' and 'user_message' keys
"""
# Format the context
context_str = self.format_context(result)
# Inject into appropriate location
if system_prompt:
# Add to system prompt
injected_system = self._inject_into_system_prompt(system_prompt, context_str)
return {
"system_prompt": injected_system,
"user_message": user_message or ""
}
elif user_message:
# Add to user message
injected_user = self._inject_into_user_message(user_message, context_str)
return {
"system_prompt": "",
"user_message": injected_user
}
else:
return {
"system_prompt": context_str,
"user_message": ""
}
def format_context(self, result: FastContextResult) -> str:
"""Format FastContext result as context string.
Args:
result: FastContext search result
Returns:
Formatted context string
"""
if self.config.format_style == "markdown":
return self._format_markdown(result)
elif self.config.format_style == "xml":
return self._format_xml(result)
else:
return self._format_plain(result)
def _format_markdown(self, result: FastContextResult) -> str:
"""Format result as markdown.
Args:
result: FastContext search result
Returns:
Markdown formatted string
"""
if not result.files:
return ""
lines = ["# Relevant Code Context"]
lines.append(f"*Found {result.total_files} file(s) in {result.search_time_ms}ms*\n")
token_budget = self.config.max_tokens
files_included = 0
# Sort by relevance
sorted_files = sorted(
result.files,
key=lambda f: f.relevance_score,
reverse=True
)
# Apply precision filter if enabled
if self.config.prioritize_precision:
sorted_files = [f for f in sorted_files if f.relevance_score >= 0.5]
for file_match in sorted_files:
if files_included >= self.config.max_files:
break
file_section = self._format_file_markdown(file_match)
section_tokens = self.estimate_tokens(file_section)
if section_tokens > token_budget:
# Try truncated version
file_section = self._format_file_markdown(file_match, truncate=True)
section_tokens = self.estimate_tokens(file_section)
if section_tokens > token_budget:
continue
lines.append(file_section)
token_budget -= section_tokens
files_included += 1
if files_included < result.total_files:
lines.append(f"\n*... and {result.total_files - files_included} more files*")
return "\n".join(lines)
def _format_file_markdown(
self,
file_match: FileMatch,
truncate: bool = False
) -> str:
"""Format a single file match as markdown.
Args:
file_match: File match to format
truncate: Whether to truncate content
Returns:
Markdown formatted string
"""
lines = [f"## `{file_match.path}`"]
if not self.config.include_file_content:
if file_match.line_ranges:
ranges = ", ".join(
f"L{lr.start}-{lr.end}" for lr in file_match.line_ranges[:3]
)
lines.append(f"Relevant lines: {ranges}")
return "\n".join(lines)
max_lines = self.config.max_lines_per_file
if truncate:
max_lines = max_lines // 2
lines_shown = 0
for lr in file_match.line_ranges:
if lines_shown >= max_lines:
break
if self.config.include_line_numbers:
lines.append(f"Lines {lr.start}-{lr.end}:")
if lr.content:
content = lr.content
content_lines = content.split("\n")
if len(content_lines) > max_lines - lines_shown:
content_lines = content_lines[:max_lines - lines_shown]
content = "\n".join(content_lines) + "\n..."
lines.append(f"```\n{content}\n```")
lines_shown += len(content_lines)
return "\n".join(lines)
def _format_xml(self, result: FastContextResult) -> str:
"""Format result as XML.
Args:
result: FastContext search result
Returns:
XML formatted string
"""
if not result.files:
return ""
lines = ["<code_context>"]
lines.append(f" <summary files=\"{result.total_files}\" search_time_ms=\"{result.search_time_ms}\"/>")
token_budget = self.config.max_tokens
files_included = 0
sorted_files = sorted(
result.files,
key=lambda f: f.relevance_score,
reverse=True
)
for file_match in sorted_files[:self.config.max_files]:
if files_included >= self.config.max_files:
break
file_xml = self._format_file_xml(file_match)
section_tokens = self.estimate_tokens(file_xml)
if section_tokens > token_budget:
continue
lines.append(file_xml)
token_budget -= section_tokens
files_included += 1
lines.append("</code_context>")
return "\n".join(lines)
def _format_file_xml(self, file_match: FileMatch) -> str:
"""Format a single file match as XML.
Args:
file_match: File match to format
Returns:
XML formatted string
"""
lines = [f' <file path="{file_match.path}" relevance="{file_match.relevance_score:.2f}">']
for lr in file_match.line_ranges[:3]:
lines.append(f' <lines start="{lr.start}" end="{lr.end}">')
if lr.content and self.config.include_file_content:
# Escape XML special characters
content = lr.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines.append(f" <![CDATA[{content}]]>")
lines.append(" </lines>")
lines.append(" </file>")
return "\n".join(lines)
def _format_plain(self, result: FastContextResult) -> str:
"""Format result as plain text.
Args:
result: FastContext search result
Returns:
Plain text formatted string
"""
if not result.files:
return ""
lines = [f"Code Context ({result.total_files} files):"]
for file_match in result.files[:self.config.max_files]:
lines.append(f"\nFile: {file_match.path}")
for lr in file_match.line_ranges[:3]:
lines.append(f" Lines {lr.start}-{lr.end}")
if lr.content and self.config.include_file_content:
for line in lr.content.split("\n")[:10]:
lines.append(f" {line}")
return "\n".join(lines)
def _inject_into_system_prompt(
self,
system_prompt: str,
context: str
) -> str:
"""Inject context into system prompt.
Args:
system_prompt: Original system prompt
context: Context to inject
Returns:
Modified system prompt
"""
if not context:
return system_prompt
# Add context section
return f"{system_prompt}\n\n{context}"
def _inject_into_user_message(
self,
user_message: str,
context: str
) -> str:
"""Inject context into user message.
Args:
user_message: Original user message
context: Context to inject
Returns:
Modified user message
"""
if not context:
return user_message
# Prepend context
return f"{context}\n\n---\n\n{user_message}"
def inject_fast_context(
result: FastContextResult,
system_prompt: Optional[str] = None,
user_message: Optional[str] = None,
max_tokens: int = 4000,
format_style: str = "markdown"
) -> Dict[str, str]:
"""Convenience function to inject FastContext results.
Args:
result: FastContext search result
system_prompt: Original system prompt
user_message: Original user message
max_tokens: Maximum tokens for context
format_style: Format style ('markdown', 'xml', 'plain')
Returns:
Dict with 'system_prompt' and 'user_message' keys
"""
config = InjectionConfig(
max_tokens=max_tokens,
format_style=format_style
)
injector = ContextInjector(config)
return injector.inject(result, system_prompt, user_message)
@@ -0,0 +1,384 @@
"""
FastContext - High-level API for fast parallel code search.
This module provides the main FastContext class that can be used
standalone or integrated with the main Agent class.
Example usage:
# Standalone usage
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path="/path/to/project")
result = fc.search("find authentication handlers")
print(result.to_context_string())
# With Agent integration
from praisonaiagents import Agent
agent = Agent(
name="CodeAssistant",
fast_context=True,
fast_context_path="/path/to/project"
)
"""
import os
import hashlib
import json
import logging
from typing import Optional, List, Dict, Any
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
logger = logging.getLogger(__name__)
class FastContext:
"""High-level API for fast parallel code search.
FastContext provides rapid code search capabilities using:
- Parallel tool execution (up to 8 concurrent calls)
- Limited turns (max 4) for fast response
- Optional LLM-powered intelligent search
- Result caching for repeated queries
Attributes:
workspace_path: Root directory for searches
model: LLM model for intelligent search
max_turns: Maximum search turns
max_parallel: Maximum parallel tool calls
cache_enabled: Whether to cache results
"""
# Environment variable names for configuration
ENV_MODEL = "FAST_CONTEXT_MODEL"
ENV_MAX_TURNS = "FAST_CONTEXT_MAX_TURNS"
ENV_MAX_PARALLEL = "FAST_CONTEXT_PARALLELISM"
ENV_TIMEOUT = "FAST_CONTEXT_TIMEOUT"
ENV_CACHE_ENABLED = "FAST_CONTEXT_CACHE"
ENV_CACHE_TTL = "FAST_CONTEXT_CACHE_TTL"
def __init__(
self,
workspace_path: Optional[str] = None,
model: Optional[str] = None,
max_turns: Optional[int] = None,
max_parallel: Optional[int] = None,
timeout: Optional[float] = None,
cache_enabled: Optional[bool] = None,
cache_ttl: Optional[int] = None,
verbose: bool = False
):
"""Initialize FastContext.
Configuration can be set via parameters or environment variables.
Parameters take precedence over environment variables.
Environment variables:
FAST_CONTEXT_MODEL: LLM model name (default: gpt-4o-mini)
FAST_CONTEXT_MAX_TURNS: Maximum search turns (default: 4)
FAST_CONTEXT_PARALLELISM: Max parallel calls (default: 8)
FAST_CONTEXT_TIMEOUT: Timeout in seconds (default: 30.0)
FAST_CONTEXT_CACHE: Enable caching (default: true)
FAST_CONTEXT_CACHE_TTL: Cache TTL in seconds (default: 300)
Args:
workspace_path: Root directory for searches (defaults to cwd)
model: LLM model for intelligent search
max_turns: Maximum search turns
max_parallel: Maximum parallel tool calls per turn
timeout: Timeout per tool call in seconds
cache_enabled: Whether to cache search results
cache_ttl: Cache time-to-live in seconds
verbose: If True, print debug information
"""
self.workspace_path = os.path.abspath(workspace_path or os.getcwd())
# Apply environment variable overrides with defaults
self.model = model or os.environ.get(self.ENV_MODEL, "gpt-4o-mini")
self.max_turns = max_turns if max_turns is not None else int(os.environ.get(self.ENV_MAX_TURNS, "4"))
self.max_parallel = max_parallel if max_parallel is not None else int(os.environ.get(self.ENV_MAX_PARALLEL, "8"))
self.timeout = timeout if timeout is not None else float(os.environ.get(self.ENV_TIMEOUT, "30.0"))
# Cache settings
cache_env = os.environ.get(self.ENV_CACHE_ENABLED, "true").lower()
self.cache_enabled = cache_enabled if cache_enabled is not None else cache_env in ("true", "1", "yes")
self.cache_ttl = cache_ttl if cache_ttl is not None else int(os.environ.get(self.ENV_CACHE_TTL, "300"))
self.verbose = verbose
# Cache storage
self._cache: Dict[str, Dict[str, Any]] = {}
# Agent instance (lazy loaded)
self._agent: Optional[FastContextAgent] = None
def _get_agent(self) -> FastContextAgent:
"""Get or create FastContextAgent instance."""
if self._agent is None:
self._agent = FastContextAgent(
workspace_path=self.workspace_path,
max_turns=self.max_turns,
max_parallel=self.max_parallel,
model=self.model,
timeout=self.timeout,
verbose=self.verbose
)
return self._agent
def _get_cache_key(self, query: str, **kwargs) -> str:
"""Generate cache key for a query."""
key_data = {
"query": query,
"workspace": self.workspace_path,
**kwargs
}
key_str = json.dumps(key_data, sort_keys=True)
return hashlib.md5(key_str.encode()).hexdigest()
def _get_cached(self, cache_key: str) -> Optional[FastContextResult]:
"""Get cached result if valid."""
if not self.cache_enabled:
return None
import time
cached = self._cache.get(cache_key)
if cached:
if time.time() - cached["timestamp"] < self.cache_ttl:
result = cached["result"]
result.from_cache = True
return result
else:
# Expired
del self._cache[cache_key]
return None
def _set_cached(self, cache_key: str, result: FastContextResult) -> None:
"""Cache a result."""
if not self.cache_enabled:
return
import time
self._cache[cache_key] = {
"result": result,
"timestamp": time.time()
}
def search(
self,
query: str,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None,
use_llm: bool = False
) -> FastContextResult:
"""Search for code relevant to the query.
Args:
query: Search query (pattern or natural language)
include_patterns: Glob patterns to include (e.g., ["*.py"])
exclude_patterns: Glob patterns to exclude
use_llm: If True, use LLM for intelligent search
Returns:
FastContextResult with matching files and lines
"""
# Check cache
cache_key = self._get_cache_key(
query,
include_patterns=include_patterns,
exclude_patterns=exclude_patterns,
use_llm=use_llm
)
cached = self._get_cached(cache_key)
if cached:
if self.verbose:
logger.info(f"Cache hit for query: {query}")
return cached
# Perform search
agent = self._get_agent()
if use_llm:
result = agent.search(query, include_patterns, exclude_patterns)
else:
result = agent.search_simple(query, include_patterns, exclude_patterns)
# Cache result
self._set_cached(cache_key, result)
return result
def search_files(
self,
pattern: str,
max_results: int = 50
) -> FastContextResult:
"""Search for files matching a glob pattern.
Args:
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
max_results: Maximum results to return
Returns:
FastContextResult with matching files
"""
from praisonaiagents.context.fast.search_tools import glob_search
result = FastContextResult(query=pattern)
result.start_timer()
matches = glob_search(
self.workspace_path,
pattern,
max_results=max_results
)
for match in matches:
result.add_file(FileMatch(
path=match["path"],
relevance_score=1.0
))
result.stop_timer()
result.turns_used = 1
result.total_tool_calls = 1
return result
def read_context(
self,
filepath: str,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
context_lines: int = 5
) -> Optional[str]:
"""Read file content with context.
Args:
filepath: Path to file (relative to workspace)
start_line: Starting line (1-indexed)
end_line: Ending line (1-indexed)
context_lines: Additional context lines
Returns:
File content string or None if error
"""
from praisonaiagents.context.fast.search_tools import read_file
full_path = filepath
if not os.path.isabs(filepath):
full_path = os.path.join(self.workspace_path, filepath)
result = read_file(
full_path,
start_line=start_line,
end_line=end_line,
context_lines=context_lines
)
if result["success"]:
return result["content"]
return None
def get_context_for_agent(
self,
query: str,
max_files: int = 10,
max_lines_per_file: int = 100,
include_content: bool = True
) -> str:
"""Get formatted context string for an agent.
This method searches for relevant code and formats it
as a context string that can be injected into an agent's
system prompt or message.
Args:
query: Search query
max_files: Maximum files to include
max_lines_per_file: Maximum lines per file
include_content: Whether to include file content
Returns:
Formatted context string
"""
result = self.search(query)
if not result.files:
return f"No relevant code found for: {query}"
lines = [f"# Relevant Code Context for: {query}"]
lines.append(f"Found {result.total_files} relevant file(s) in {result.search_time_ms}ms\n")
for i, file_match in enumerate(result.files[:max_files]):
lines.append(f"## {file_match.path}")
if include_content and file_match.line_ranges:
for lr in file_match.line_ranges:
# Limit lines
if lr.line_count > max_lines_per_file:
lr = LineRange(
start=lr.start,
end=lr.start + max_lines_per_file - 1,
content=lr.content,
relevance_score=lr.relevance_score
)
lines.append(f"Lines {lr.start}-{lr.end}:")
# Read content if not already present
if lr.content:
lines.append(f"```\n{lr.content}\n```")
else:
content = self.read_context(
file_match.path,
start_line=lr.start,
end_line=lr.end
)
if content:
lines.append(f"```\n{content}\n```")
lines.append("")
if len(result.files) > max_files:
lines.append(f"... and {len(result.files) - max_files} more files")
return "\n".join(lines)
def clear_cache(self) -> None:
"""Clear the result cache."""
self._cache.clear()
def close(self) -> None:
"""Close resources."""
if self._agent:
self._agent.close()
self._agent = None
self._cache.clear()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# Convenience function for quick searches
def fast_search(
query: str,
workspace_path: Optional[str] = None,
**kwargs
) -> FastContextResult:
"""Convenience function for quick code search.
Args:
query: Search query
workspace_path: Root directory (defaults to cwd)
**kwargs: Additional arguments for FastContext
Returns:
FastContextResult with matching files
"""
with FastContext(workspace_path=workspace_path, **kwargs) as fc:
return fc.search(query)
@@ -0,0 +1,392 @@
"""
FastContextAgent - Specialized subagent for rapid code search.
This agent is designed for fast context retrieval using:
- Parallel tool execution (up to 8 concurrent calls)
- Limited turns (max 4) for fast response
- Lightweight model (gpt-4o-mini by default)
- Restricted tool set (grep, glob, read, list_dir)
"""
import os
import json
import logging
from typing import List, Dict, Any, Optional
from praisonaiagents.context.fast.result import (
FastContextResult,
FileMatch,
LineRange
)
from praisonaiagents.context.fast.search_tools import (
grep_search,
glob_search,
read_file,
list_directory,
FAST_CONTEXT_TOOLS
)
from praisonaiagents.context.fast.parallel_executor import (
ToolCallBatch,
ParallelSearchCoordinator
)
logger = logging.getLogger(__name__)
class FastContextAgent:
"""Specialized agent for fast parallel code search.
This agent performs rapid code search using parallel tool calls
and a lightweight model optimized for retrieval tasks.
Attributes:
workspace_path: Root directory for searches
max_turns: Maximum search turns (default: 4)
max_parallel: Maximum parallel tool calls (default: 8)
model: LLM model for intelligent search (default: gpt-4o-mini)
timeout: Timeout per tool call in seconds
"""
def __init__(
self,
workspace_path: str,
max_turns: int = 4,
max_parallel: int = 8,
model: str = "gpt-4o-mini",
timeout: float = 30.0,
verbose: bool = False
):
"""Initialize FastContextAgent.
Args:
workspace_path: Root directory for searches
max_turns: Maximum search turns
max_parallel: Maximum parallel tool calls per turn
model: LLM model name for intelligent search
timeout: Timeout per tool call in seconds
verbose: If True, print debug information
"""
self.workspace_path = os.path.abspath(workspace_path)
self.max_turns = max_turns
self.max_parallel = max_parallel
self.model = model
self.timeout = timeout
self.verbose = verbose
# Initialize coordinator
self.coordinator = ParallelSearchCoordinator(
max_parallel=max_parallel,
max_turns=max_turns,
timeout=timeout
)
# LLM instance (lazy loaded)
self._llm = None
def _get_llm(self):
"""Get or create LLM instance (lazy loading)."""
if self._llm is None:
try:
from praisonaiagents.llm import LLM
self._llm = LLM(model=self.model)
except Exception as e:
logger.warning(f"Could not initialize LLM: {e}")
self._llm = None
return self._llm
def get_tools(self) -> List[Dict[str, Any]]:
"""Get tool definitions for LLM function calling.
Returns:
List of tool definition dictionaries
"""
return FAST_CONTEXT_TOOLS
def execute_tool(self, tool_name: str, **kwargs) -> Any:
"""Execute a single tool.
Args:
tool_name: Name of the tool
**kwargs: Tool arguments
Returns:
Tool result
"""
# Prepend workspace path if needed
if tool_name in ("grep_search", "glob_search"):
if "search_path" not in kwargs or kwargs["search_path"] == ".":
kwargs["search_path"] = self.workspace_path
elif not os.path.isabs(kwargs["search_path"]):
kwargs["search_path"] = os.path.join(
self.workspace_path, kwargs["search_path"]
)
elif tool_name == "list_directory":
if "dir_path" not in kwargs or kwargs["dir_path"] == ".":
kwargs["dir_path"] = self.workspace_path
elif not os.path.isabs(kwargs["dir_path"]):
kwargs["dir_path"] = os.path.join(
self.workspace_path, kwargs["dir_path"]
)
elif tool_name == "read_file":
if "filepath" in kwargs and not os.path.isabs(kwargs["filepath"]):
kwargs["filepath"] = os.path.join(
self.workspace_path, kwargs["filepath"]
)
tools = {
"grep_search": grep_search,
"glob_search": glob_search,
"read_file": read_file,
"list_directory": list_directory
}
if tool_name not in tools:
return {"error": f"Unknown tool: {tool_name}"}
try:
return tools[tool_name](**kwargs)
except Exception as e:
return {"error": str(e)}
def search_simple(
self,
query: str,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None,
max_results: int = 50
) -> FastContextResult:
"""Perform a simple search without LLM.
This is a fast, direct search that doesn't use the LLM
for query understanding. Good for simple pattern searches.
Args:
query: Search pattern
include_patterns: Glob patterns to include
exclude_patterns: Glob patterns to exclude
max_results: Maximum results per tool
Returns:
FastContextResult with matching files and lines
"""
result = FastContextResult(query=query)
result.start_timer()
# Execute grep search
grep_results = grep_search(
search_path=self.workspace_path,
pattern=query,
include_patterns=include_patterns,
exclude_patterns=exclude_patterns,
max_results=max_results,
context_lines=2
)
# Convert to FastContextResult format
for match in grep_results:
file_match = FileMatch(
path=match["path"],
relevance_score=1.0,
match_count=1
)
file_match.add_line_range(LineRange(
start=max(1, match["line_number"] - 2),
end=match["line_number"] + 2,
content=match.get("context"),
relevance_score=1.0
))
result.add_file(file_match)
result.stop_timer()
result.turns_used = 1
result.total_tool_calls = 1
return result
def search(
self,
query: str,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None
) -> FastContextResult:
"""Perform intelligent search using LLM.
Uses the LLM to understand the query and plan search strategy,
then executes parallel tool calls to find relevant code.
Args:
query: Natural language search query
include_patterns: Glob patterns to include
exclude_patterns: Glob patterns to exclude
Returns:
FastContextResult with matching files and lines
"""
result = FastContextResult(query=query)
result.start_timer()
llm = self._get_llm()
# If no LLM available, fall back to simple search
if llm is None:
logger.info("LLM not available, falling back to simple search")
return self.search_simple(query, include_patterns, exclude_patterns)
self.coordinator.start()
# System prompt for the search agent
system_prompt = f"""You are a fast code search agent. Your task is to find relevant code in a codebase.
Workspace: {self.workspace_path}
You have access to these tools:
- grep_search: Search for patterns in files
- glob_search: Find files by pattern
- read_file: Read file contents
- list_directory: List directory contents
Strategy:
1. Start with broad searches (glob, list_directory) to understand structure
2. Use grep_search to find specific patterns
3. Use read_file to get detailed context for relevant files
4. Execute up to {self.max_parallel} tool calls in parallel per turn
5. Complete search in {self.max_turns} turns or less
Return files and line ranges that are relevant to the query.
Be efficient - use parallel tool calls to explore multiple paths at once."""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Find code relevant to: {query}"}
]
# Add file pattern constraints if provided
if include_patterns:
messages[1]["content"] += f"\nOnly search in files matching: {include_patterns}"
if exclude_patterns:
messages[1]["content"] += f"\nExclude files matching: {exclude_patterns}"
try:
# Multi-turn search loop
for turn in range(self.max_turns):
if self.verbose:
logger.info(f"Search turn {turn + 1}/{self.max_turns}")
# Get LLM response with tool calls
response = llm.client.chat.completions.create(
model=self.model,
messages=messages,
tools=[{"type": "function", "function": t} for t in self.get_tools()],
tool_choice="auto"
)
message = response.choices[0].message
# Check if we have tool calls
if not message.tool_calls:
# No more tool calls, search complete
break
# Execute tool calls in parallel
batch = ToolCallBatch(max_size=self.max_parallel)
tool_call_map = {} # Map tool call ID to batch index
for tool_call in message.tool_calls[:self.max_parallel]:
try:
args = json.loads(tool_call.function.arguments)
batch.add(tool_call.function.name, **args)
tool_call_map[tool_call.id] = len(batch.tasks) - 1
except json.JSONDecodeError:
continue
# Execute batch
if batch.tasks:
tool_results = self.coordinator.execute_turn_sync(batch)
# Process results
messages.append({"role": "assistant", "content": None, "tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in message.tool_calls[:self.max_parallel]
]})
# Add tool results to messages
for tool_call in message.tool_calls[:self.max_parallel]:
idx = tool_call_map.get(tool_call.id)
if idx is not None and idx < len(tool_results):
tool_result = tool_results[idx]
# Process grep results into FastContextResult
if tool_call.function.name == "grep_search":
if isinstance(tool_result, list):
for match in tool_result:
file_match = FileMatch(
path=match.get("path", ""),
relevance_score=1.0,
match_count=1
)
line_num = match.get("line_number", 1)
file_match.add_line_range(LineRange(
start=max(1, line_num - 2),
end=line_num + 2,
content=match.get("context"),
relevance_score=1.0
))
result.add_file(file_match)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result) if not isinstance(tool_result, str) else tool_result
})
except Exception as e:
logger.error(f"Error during LLM search: {e}")
# Fall back to simple search
return self.search_simple(query, include_patterns, exclude_patterns)
finally:
self.coordinator.stop()
result.stop_timer()
result.turns_used = self.coordinator.turns_used
result.total_tool_calls = self.coordinator.total_tool_calls
result.sort_by_relevance()
return result
async def search_async(
self,
query: str,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None
) -> FastContextResult:
"""Async version of search.
Args:
query: Natural language search query
include_patterns: Glob patterns to include
exclude_patterns: Glob patterns to exclude
Returns:
FastContextResult with matching files and lines
"""
# For now, wrap sync version
# TODO: Implement true async with async LLM calls
return self.search(query, include_patterns, exclude_patterns)
def close(self) -> None:
"""Close resources."""
self.coordinator.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@@ -0,0 +1,23 @@
"""
Indexer module for Fast Context.
Provides file and symbol indexing capabilities for fast code search.
"""
def __getattr__(name):
"""Lazy load indexer classes."""
if name == "FileIndexer":
from .file_indexer import FileIndexer
return FileIndexer
elif name == "SymbolIndexer":
from .symbol_indexer import SymbolIndexer
return SymbolIndexer
elif name == "Symbol":
from .symbol_indexer import Symbol
return Symbol
elif name == "SymbolType":
from .symbol_indexer import SymbolType
return SymbolType
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["FileIndexer", "SymbolIndexer", "Symbol", "SymbolType"]
@@ -0,0 +1,445 @@
"""
File Indexer for Fast Context.
Provides file indexing capabilities for faster code search:
- Indexes file paths and metadata
- Supports .gitignore and .praisonignore patterns
- Incremental updates on file changes
- Memory-efficient storage
"""
import os
import fnmatch
import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Dict, List, Set, Optional, Any
from pathlib import Path
import time
logger = logging.getLogger(__name__)
@dataclass
class FileInfo:
"""Information about an indexed file.
Attributes:
path: Relative path from workspace root
absolute_path: Absolute file path
size: File size in bytes
modified: Last modification timestamp
extension: File extension (lowercase)
hash: Content hash for change detection
"""
path: str
absolute_path: str
size: int
modified: float
extension: str
hash: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"path": self.path,
"absolute_path": self.absolute_path,
"size": self.size,
"modified": self.modified,
"extension": self.extension,
"hash": self.hash
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "FileInfo":
"""Create from dictionary."""
return cls(**data)
class FileIndexer:
"""Indexes files in a workspace for fast search.
The indexer maintains an in-memory index of files that can be
used to quickly find files by pattern without filesystem traversal.
Attributes:
workspace_path: Root directory to index
files: Dictionary mapping relative paths to FileInfo
extensions: Set of indexed file extensions
"""
# Default extensions to index
DEFAULT_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs",
".c", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift",
".kt", ".scala", ".r", ".m", ".mm", ".sql", ".sh", ".bash",
".yaml", ".yml", ".json", ".xml", ".html", ".css", ".scss",
".md", ".txt", ".rst", ".toml", ".ini", ".cfg", ".conf"
}
# Default patterns to ignore
DEFAULT_IGNORE_PATTERNS = {
"__pycache__", "*.pyc", "*.pyo", ".git", ".svn", ".hg",
"node_modules", "venv", ".venv", "env", ".env",
"dist", "build", ".cache", ".pytest_cache",
"*.egg-info", "*.egg", "*.whl", "*.so", "*.dll", "*.dylib"
}
def __init__(
self,
workspace_path: str,
extensions: Optional[Set[str]] = None,
ignore_patterns: Optional[Set[str]] = None,
respect_gitignore: bool = True,
max_file_size: int = 10 * 1024 * 1024, # 10MB
compute_hashes: bool = False
):
"""Initialize file indexer.
Args:
workspace_path: Root directory to index
extensions: File extensions to index (None = all text files)
ignore_patterns: Patterns to ignore
respect_gitignore: Whether to respect .gitignore
max_file_size: Maximum file size to index
compute_hashes: Whether to compute content hashes
"""
self.workspace_path = os.path.abspath(workspace_path)
self.extensions = extensions or self.DEFAULT_EXTENSIONS
self.ignore_patterns = ignore_patterns or self.DEFAULT_IGNORE_PATTERNS.copy()
self.respect_gitignore = respect_gitignore
self.max_file_size = max_file_size
self.compute_hashes = compute_hashes
# Index storage
self.files: Dict[str, FileInfo] = {}
self.by_extension: Dict[str, List[str]] = {}
# Gitignore patterns
self._gitignore_patterns: Set[str] = set()
# Index metadata
self.last_indexed: Optional[float] = None
self.total_files: int = 0
self.total_size: int = 0
def _load_gitignore(self) -> None:
"""Load patterns from .gitignore and .praisonignore."""
self._gitignore_patterns.clear()
for filename in [".gitignore", ".praisonignore"]:
filepath = os.path.join(self.workspace_path, filename)
if os.path.exists(filepath):
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
self._gitignore_patterns.add(line)
except Exception:
pass
def _should_ignore(self, path: str, is_dir: bool = False) -> bool:
"""Check if a path should be ignored.
Args:
path: Relative path from workspace
is_dir: Whether the path is a directory
Returns:
True if path should be ignored
"""
name = os.path.basename(path)
parts = Path(path).parts
# Check default ignore patterns
for pattern in self.ignore_patterns:
if fnmatch.fnmatch(name, pattern):
return True
if any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
# Check gitignore patterns
if self.respect_gitignore:
for pattern in self._gitignore_patterns:
# Handle directory patterns
if pattern.endswith('/'):
dir_pattern = pattern.rstrip('/')
if is_dir and fnmatch.fnmatch(name, dir_pattern):
return True
if any(fnmatch.fnmatch(part, dir_pattern) for part in parts):
return True
# Handle ** patterns
elif '**' in pattern:
fnmatch_pattern = pattern.replace('**/', '*').replace('**', '*')
if fnmatch.fnmatch(path, fnmatch_pattern):
return True
# Handle simple patterns
else:
if fnmatch.fnmatch(name, pattern):
return True
if fnmatch.fnmatch(path, pattern):
return True
return False
def _compute_hash(self, filepath: str) -> Optional[str]:
"""Compute MD5 hash of file content.
Args:
filepath: Path to file
Returns:
MD5 hash string or None on error
"""
if not self.compute_hashes:
return None
try:
hasher = hashlib.md5()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
hasher.update(chunk)
return hasher.hexdigest()
except Exception:
return None
def index(self) -> int:
"""Index all files in the workspace.
Returns:
Number of files indexed
"""
start_time = time.perf_counter()
# Load gitignore patterns
if self.respect_gitignore:
self._load_gitignore()
# Clear existing index
self.files.clear()
self.by_extension.clear()
self.total_files = 0
self.total_size = 0
# Walk directory tree
for root, dirs, files in os.walk(self.workspace_path):
# Get relative path
rel_root = os.path.relpath(root, self.workspace_path)
if rel_root == '.':
rel_root = ''
# Filter directories in-place
dirs[:] = [d for d in dirs if not self._should_ignore(
os.path.join(rel_root, d) if rel_root else d, is_dir=True
)]
for filename in files:
rel_path = os.path.join(rel_root, filename) if rel_root else filename
abs_path = os.path.join(root, filename)
# Check if should ignore
if self._should_ignore(rel_path):
continue
# Check extension
ext = os.path.splitext(filename)[1].lower()
if self.extensions and ext not in self.extensions:
continue
try:
stat = os.stat(abs_path)
# Check file size
if stat.st_size > self.max_file_size:
continue
# Create file info
file_info = FileInfo(
path=rel_path,
absolute_path=abs_path,
size=stat.st_size,
modified=stat.st_mtime,
extension=ext,
hash=self._compute_hash(abs_path)
)
# Add to index
self.files[rel_path] = file_info
# Add to extension index
if ext not in self.by_extension:
self.by_extension[ext] = []
self.by_extension[ext].append(rel_path)
self.total_files += 1
self.total_size += stat.st_size
except Exception as e:
logger.debug(f"Error indexing {abs_path}: {e}")
continue
self.last_indexed = time.perf_counter()
elapsed = self.last_indexed - start_time
logger.info(f"Indexed {self.total_files} files ({self.total_size / 1024 / 1024:.2f} MB) in {elapsed:.2f}s")
return self.total_files
def find_by_pattern(self, pattern: str) -> List[FileInfo]:
"""Find files matching a glob pattern.
Args:
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
Returns:
List of matching FileInfo objects
"""
results = []
for rel_path, file_info in self.files.items():
if fnmatch.fnmatch(rel_path, pattern):
results.append(file_info)
elif fnmatch.fnmatch(os.path.basename(rel_path), pattern):
results.append(file_info)
return results
def find_by_extension(self, extension: str) -> List[FileInfo]:
"""Find files by extension.
Args:
extension: File extension (with or without dot)
Returns:
List of matching FileInfo objects
"""
if not extension.startswith('.'):
extension = '.' + extension
extension = extension.lower()
paths = self.by_extension.get(extension, [])
return [self.files[p] for p in paths if p in self.files]
def find_by_name(self, name: str, exact: bool = False) -> List[FileInfo]:
"""Find files by name.
Args:
name: Filename to search for
exact: If True, match exact name; otherwise, partial match
Returns:
List of matching FileInfo objects
"""
results = []
name_lower = name.lower()
for rel_path, file_info in self.files.items():
filename = os.path.basename(rel_path).lower()
if exact:
if filename == name_lower:
results.append(file_info)
else:
if name_lower in filename:
results.append(file_info)
return results
def get_file(self, path: str) -> Optional[FileInfo]:
"""Get file info by path.
Args:
path: Relative path from workspace
Returns:
FileInfo or None if not found
"""
return self.files.get(path)
def is_stale(self, max_age: float = 60.0) -> bool:
"""Check if index is stale.
Args:
max_age: Maximum age in seconds
Returns:
True if index is stale or not indexed
"""
if self.last_indexed is None:
return True
return (time.perf_counter() - self.last_indexed) > max_age
def get_stats(self) -> Dict[str, Any]:
"""Get index statistics.
Returns:
Dictionary with index stats
"""
return {
"workspace_path": self.workspace_path,
"total_files": self.total_files,
"total_size_bytes": self.total_size,
"total_size_mb": round(self.total_size / 1024 / 1024, 2),
"extensions": dict(sorted(
((ext, len(paths)) for ext, paths in self.by_extension.items()),
key=lambda x: x[1],
reverse=True
)),
"last_indexed": self.last_indexed
}
def save_index(self, filepath: str) -> None:
"""Save index to file.
Args:
filepath: Path to save index
"""
data = {
"workspace_path": self.workspace_path,
"last_indexed": self.last_indexed,
"files": {path: info.to_dict() for path, info in self.files.items()}
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
def load_index(self, filepath: str) -> bool:
"""Load index from file.
Args:
filepath: Path to load index from
Returns:
True if loaded successfully
"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
# Verify workspace path matches
if data.get("workspace_path") != self.workspace_path:
return False
self.last_indexed = data.get("last_indexed")
self.files.clear()
self.by_extension.clear()
self.total_files = 0
self.total_size = 0
for path, info_dict in data.get("files", {}).items():
file_info = FileInfo.from_dict(info_dict)
self.files[path] = file_info
ext = file_info.extension
if ext not in self.by_extension:
self.by_extension[ext] = []
self.by_extension[ext].append(path)
self.total_files += 1
self.total_size += file_info.size
return True
except Exception as e:
logger.debug(f"Error loading index: {e}")
return False
@@ -0,0 +1,321 @@
"""
File Watcher for Incremental Indexing.
Monitors file system changes and triggers incremental index updates:
- Watches for file creation, modification, deletion
- Debounces rapid changes
- Supports background indexing
"""
import os
import time
import logging
import threading
from typing import Dict, Set, Callable, Optional, Any
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
class ChangeType(Enum):
"""Types of file system changes."""
CREATED = "created"
MODIFIED = "modified"
DELETED = "deleted"
@dataclass
class FileChange:
"""Represents a file system change.
Attributes:
path: Path to the changed file
change_type: Type of change
timestamp: When the change was detected
"""
path: str
change_type: ChangeType
timestamp: float
class FileWatcher:
"""Watches for file system changes and triggers callbacks.
Uses polling-based approach for cross-platform compatibility.
For production use, consider using watchdog library.
Attributes:
workspace_path: Root directory to watch
extensions: File extensions to watch
poll_interval: Seconds between polls
"""
def __init__(
self,
workspace_path: str,
extensions: Optional[Set[str]] = None,
poll_interval: float = 1.0,
debounce_delay: float = 0.5,
on_change: Optional[Callable[[FileChange], None]] = None
):
"""Initialize file watcher.
Args:
workspace_path: Root directory to watch
extensions: File extensions to watch (None = all)
poll_interval: Seconds between polls
debounce_delay: Delay before processing changes
on_change: Callback for file changes
"""
self.workspace_path = os.path.abspath(workspace_path)
self.extensions = extensions or {
'.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java',
'.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift'
}
self.poll_interval = poll_interval
self.debounce_delay = debounce_delay
self.on_change = on_change
# State tracking
self._file_mtimes: Dict[str, float] = {}
self._pending_changes: Dict[str, FileChange] = {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _scan_files(self) -> Dict[str, float]:
"""Scan workspace and get file modification times.
Returns:
Dict mapping file paths to modification times
"""
mtimes = {}
for root, dirs, files in os.walk(self.workspace_path):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.')]
for filename in files:
ext = os.path.splitext(filename)[1].lower()
if ext in self.extensions:
file_path = os.path.join(root, filename)
try:
mtimes[file_path] = os.path.getmtime(file_path)
except OSError:
pass
return mtimes
def _detect_changes(self, new_mtimes: Dict[str, float]) -> None:
"""Detect changes between old and new file states.
Args:
new_mtimes: New file modification times
"""
old_paths = set(self._file_mtimes.keys())
new_paths = set(new_mtimes.keys())
now = time.time()
# Detect created files
for path in new_paths - old_paths:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.CREATED,
timestamp=now
))
# Detect deleted files
for path in old_paths - new_paths:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.DELETED,
timestamp=now
))
# Detect modified files
for path in old_paths & new_paths:
if new_mtimes[path] > self._file_mtimes[path]:
self._add_pending_change(FileChange(
path=path,
change_type=ChangeType.MODIFIED,
timestamp=now
))
self._file_mtimes = new_mtimes
def _add_pending_change(self, change: FileChange) -> None:
"""Add a change to pending queue (with debouncing).
Args:
change: File change to add
"""
with self._lock:
self._pending_changes[change.path] = change
def _process_pending_changes(self) -> None:
"""Process pending changes after debounce delay."""
with self._lock:
now = time.time()
to_process = []
for path, change in list(self._pending_changes.items()):
if now - change.timestamp >= self.debounce_delay:
to_process.append(change)
del self._pending_changes[path]
# Process changes outside lock
for change in to_process:
if self.on_change:
try:
self.on_change(change)
except Exception as e:
logger.error(f"Error processing change {change.path}: {e}")
def _poll_loop(self) -> None:
"""Main polling loop."""
# Initial scan
self._file_mtimes = self._scan_files()
while self._running:
try:
# Scan for changes
new_mtimes = self._scan_files()
self._detect_changes(new_mtimes)
# Process pending changes
self._process_pending_changes()
except Exception as e:
logger.error(f"Error in file watcher: {e}")
time.sleep(self.poll_interval)
def start(self) -> None:
"""Start watching for file changes."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
logger.info(f"File watcher started for {self.workspace_path}")
def stop(self) -> None:
"""Stop watching for file changes."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info("File watcher stopped")
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
class IncrementalIndexer:
"""Manages incremental index updates based on file changes.
Combines FileIndexer/SymbolIndexer with FileWatcher for
automatic index updates when files change.
"""
def __init__(
self,
workspace_path: str,
file_indexer: Optional[Any] = None,
symbol_indexer: Optional[Any] = None,
poll_interval: float = 2.0
):
"""Initialize incremental indexer.
Args:
workspace_path: Root directory to index
file_indexer: FileIndexer instance (optional)
symbol_indexer: SymbolIndexer instance (optional)
poll_interval: Seconds between file system polls
"""
self.workspace_path = os.path.abspath(workspace_path)
self.file_indexer = file_indexer
self.symbol_indexer = symbol_indexer
self._watcher = FileWatcher(
workspace_path=workspace_path,
poll_interval=poll_interval,
on_change=self._handle_change
)
# Statistics
self.changes_processed = 0
self.last_change_time: Optional[float] = None
def _handle_change(self, change: FileChange) -> None:
"""Handle a file change.
Args:
change: File change to process
"""
rel_path = os.path.relpath(change.path, self.workspace_path)
logger.debug(f"Processing {change.change_type.value}: {rel_path}")
if change.change_type == ChangeType.DELETED:
# Remove from indexes
if self.file_indexer and hasattr(self.file_indexer, 'files'):
self.file_indexer.files.pop(rel_path, None)
if self.symbol_indexer and hasattr(self.symbol_indexer, 'symbols'):
self.symbol_indexer.symbols.pop(rel_path, None)
else:
# Re-index the file
if self.file_indexer and hasattr(self.file_indexer, 'index_file'):
# FileIndexer doesn't have index_file, so we skip
pass
if self.symbol_indexer and hasattr(self.symbol_indexer, 'index_file'):
self.symbol_indexer.index_file(change.path)
self.changes_processed += 1
self.last_change_time = time.time()
def start(self) -> None:
"""Start incremental indexing."""
# Initial full index
if self.file_indexer:
self.file_indexer.index()
if self.symbol_indexer:
self.symbol_indexer.index()
# Start watching
self._watcher.start()
def stop(self) -> None:
"""Stop incremental indexing."""
self._watcher.stop()
def get_stats(self) -> Dict[str, Any]:
"""Get indexer statistics.
Returns:
Dictionary with stats
"""
stats = {
"changes_processed": self.changes_processed,
"last_change_time": self.last_change_time
}
if self.file_indexer:
stats["file_indexer"] = self.file_indexer.get_stats()
if self.symbol_indexer:
stats["symbol_indexer"] = self.symbol_indexer.get_stats()
return stats
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
@@ -0,0 +1,669 @@
"""
Symbol Indexer for Fast Context.
Extracts code symbols from source files:
- Python: functions, classes, imports
- JavaScript/TypeScript: functions, classes, imports, exports
- Go: functions, types, imports
- Rust: functions, structs, impl blocks, use statements
- Java: classes, methods, imports
"""
import os
import re
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Set, Optional, Any
from pathlib import Path
from enum import Enum
logger = logging.getLogger(__name__)
class SymbolType(Enum):
"""Types of code symbols."""
FUNCTION = "function"
CLASS = "class"
METHOD = "method"
IMPORT = "import"
EXPORT = "export"
VARIABLE = "variable"
CONSTANT = "constant"
TYPE = "type"
STRUCT = "struct"
INTERFACE = "interface"
MODULE = "module"
@dataclass
class Symbol:
"""Represents a code symbol.
Attributes:
name: Symbol name
symbol_type: Type of symbol
file_path: File containing the symbol
line_number: Line number (1-indexed)
end_line: End line number (for multi-line symbols)
signature: Full signature (for functions/methods)
parent: Parent symbol name (for methods)
docstring: Documentation string if available
"""
name: str
symbol_type: SymbolType
file_path: str
line_number: int
end_line: Optional[int] = None
signature: Optional[str] = None
parent: Optional[str] = None
docstring: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"type": self.symbol_type.value,
"file_path": self.file_path,
"line_number": self.line_number,
"end_line": self.end_line,
"signature": self.signature,
"parent": self.parent,
"docstring": self.docstring
}
class LanguageParser:
"""Base class for language-specific parsers."""
def parse(self, content: str, file_path: str) -> List[Symbol]:
"""Parse content and extract symbols.
Args:
content: File content
file_path: Path to the file
Returns:
List of extracted symbols
"""
raise NotImplementedError
class PythonParser(LanguageParser):
"""Parser for Python files."""
# Regex patterns for Python
FUNCTION_PATTERN = re.compile(
r'^(\s*)def\s+(\w+)\s*\((.*?)\)\s*(?:->.*?)?:',
re.MULTILINE
)
CLASS_PATTERN = re.compile(
r'^(\s*)class\s+(\w+)\s*(?:\((.*?)\))?:',
re.MULTILINE
)
IMPORT_PATTERN = re.compile(
r'^(?:from\s+(\S+)\s+)?import\s+(.+)$',
re.MULTILINE
)
ASYNC_FUNCTION_PATTERN = re.compile(
r'^(\s*)async\s+def\s+(\w+)\s*\((.*?)\)\s*(?:->.*?)?:',
re.MULTILINE
)
def parse(self, content: str, file_path: str) -> List[Symbol]:
symbols = []
lines = content.split('\n')
# Track class context for methods
current_class = None
class_indent = 0
# Parse classes
for match in self.CLASS_PATTERN.finditer(content):
indent = len(match.group(1))
name = match.group(2)
bases = match.group(3) or ""
line_num = content[:match.start()].count('\n') + 1
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.CLASS,
file_path=file_path,
line_number=line_num,
signature=f"class {name}({bases})" if bases else f"class {name}"
))
# Parse functions (including async)
for pattern in [self.FUNCTION_PATTERN, self.ASYNC_FUNCTION_PATTERN]:
for match in pattern.finditer(content):
indent = len(match.group(1))
name = match.group(2)
params = match.group(3)
line_num = content[:match.start()].count('\n') + 1
# Determine if it's a method (inside a class)
is_method = indent > 0
parent = None
if is_method:
# Find parent class
for sym in symbols:
if sym.symbol_type == SymbolType.CLASS:
if sym.line_number < line_num:
parent = sym.name
symbol_type = SymbolType.METHOD if is_method else SymbolType.FUNCTION
is_async = "async" in match.group(0)
prefix = "async def" if is_async else "def"
symbols.append(Symbol(
name=name,
symbol_type=symbol_type,
file_path=file_path,
line_number=line_num,
signature=f"{prefix} {name}({params})",
parent=parent
))
# Parse imports
for match in self.IMPORT_PATTERN.finditer(content):
from_module = match.group(1)
imports = match.group(2)
line_num = content[:match.start()].count('\n') + 1
if from_module:
sig = f"from {from_module} import {imports}"
else:
sig = f"import {imports}"
symbols.append(Symbol(
name=imports.split(',')[0].strip().split(' ')[0],
symbol_type=SymbolType.IMPORT,
file_path=file_path,
line_number=line_num,
signature=sig
))
return symbols
class JavaScriptParser(LanguageParser):
"""Parser for JavaScript/TypeScript files."""
FUNCTION_PATTERN = re.compile(
r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\((.*?)\)',
re.MULTILINE
)
ARROW_FUNCTION_PATTERN = re.compile(
r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\((.*?)\)\s*=>',
re.MULTILINE
)
CLASS_PATTERN = re.compile(
r'(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?',
re.MULTILINE
)
IMPORT_PATTERN = re.compile(
r'import\s+(?:{([^}]+)}|(\w+))\s+from\s+[\'"]([^\'"]+)[\'"]',
re.MULTILINE
)
EXPORT_PATTERN = re.compile(
r'export\s+(?:default\s+)?(?:const|let|var|function|class)\s+(\w+)',
re.MULTILINE
)
def parse(self, content: str, file_path: str) -> List[Symbol]:
symbols = []
# Parse functions
for match in self.FUNCTION_PATTERN.finditer(content):
name = match.group(1)
params = match.group(2)
line_num = content[:match.start()].count('\n') + 1
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.FUNCTION,
file_path=file_path,
line_number=line_num,
signature=f"function {name}({params})"
))
# Parse arrow functions
for match in self.ARROW_FUNCTION_PATTERN.finditer(content):
name = match.group(1)
params = match.group(2)
line_num = content[:match.start()].count('\n') + 1
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.FUNCTION,
file_path=file_path,
line_number=line_num,
signature=f"const {name} = ({params}) =>"
))
# Parse classes
for match in self.CLASS_PATTERN.finditer(content):
name = match.group(1)
extends = match.group(2)
line_num = content[:match.start()].count('\n') + 1
sig = f"class {name}"
if extends:
sig += f" extends {extends}"
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.CLASS,
file_path=file_path,
line_number=line_num,
signature=sig
))
# Parse imports
for match in self.IMPORT_PATTERN.finditer(content):
named = match.group(1)
default = match.group(2)
module = match.group(3)
line_num = content[:match.start()].count('\n') + 1
name = default or (named.split(',')[0].strip() if named else module)
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.IMPORT,
file_path=file_path,
line_number=line_num,
signature=f"import from '{module}'"
))
# Parse exports
for match in self.EXPORT_PATTERN.finditer(content):
name = match.group(1)
line_num = content[:match.start()].count('\n') + 1
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.EXPORT,
file_path=file_path,
line_number=line_num,
signature=f"export {name}"
))
return symbols
class GoParser(LanguageParser):
"""Parser for Go files."""
FUNCTION_PATTERN = re.compile(
r'^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\((.*?)\)',
re.MULTILINE
)
TYPE_PATTERN = re.compile(
r'^type\s+(\w+)\s+(struct|interface)',
re.MULTILINE
)
IMPORT_PATTERN = re.compile(
r'import\s+(?:\(\s*([\s\S]*?)\s*\)|"([^"]+)")',
re.MULTILINE
)
def parse(self, content: str, file_path: str) -> List[Symbol]:
symbols = []
# Parse functions
for match in self.FUNCTION_PATTERN.finditer(content):
receiver_name = match.group(1)
receiver_type = match.group(2)
name = match.group(3)
params = match.group(4)
line_num = content[:match.start()].count('\n') + 1
if receiver_type:
# Method
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.METHOD,
file_path=file_path,
line_number=line_num,
signature=f"func ({receiver_name} {receiver_type}) {name}({params})",
parent=receiver_type
))
else:
# Function
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.FUNCTION,
file_path=file_path,
line_number=line_num,
signature=f"func {name}({params})"
))
# Parse types
for match in self.TYPE_PATTERN.finditer(content):
name = match.group(1)
kind = match.group(2)
line_num = content[:match.start()].count('\n') + 1
symbol_type = SymbolType.STRUCT if kind == "struct" else SymbolType.INTERFACE
symbols.append(Symbol(
name=name,
symbol_type=symbol_type,
file_path=file_path,
line_number=line_num,
signature=f"type {name} {kind}"
))
return symbols
class RustParser(LanguageParser):
"""Parser for Rust files."""
FUNCTION_PATTERN = re.compile(
r'^(\s*)(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\((.*?)\)',
re.MULTILINE
)
STRUCT_PATTERN = re.compile(
r'^(?:pub\s+)?struct\s+(\w+)',
re.MULTILINE
)
IMPL_PATTERN = re.compile(
r'^impl(?:<[^>]*>)?\s+(?:(\w+)\s+for\s+)?(\w+)',
re.MULTILINE
)
USE_PATTERN = re.compile(
r'^use\s+(.+);',
re.MULTILINE
)
def parse(self, content: str, file_path: str) -> List[Symbol]:
symbols = []
# Parse functions
for match in self.FUNCTION_PATTERN.finditer(content):
indent = len(match.group(1))
name = match.group(2)
params = match.group(3)
line_num = content[:match.start()].count('\n') + 1
is_method = indent > 0
symbol_type = SymbolType.METHOD if is_method else SymbolType.FUNCTION
symbols.append(Symbol(
name=name,
symbol_type=symbol_type,
file_path=file_path,
line_number=line_num,
signature=f"fn {name}({params})"
))
# Parse structs
for match in self.STRUCT_PATTERN.finditer(content):
name = match.group(1)
line_num = content[:match.start()].count('\n') + 1
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.STRUCT,
file_path=file_path,
line_number=line_num,
signature=f"struct {name}"
))
# Parse impl blocks
for match in self.IMPL_PATTERN.finditer(content):
trait_name = match.group(1)
type_name = match.group(2)
line_num = content[:match.start()].count('\n') + 1
if trait_name:
sig = f"impl {trait_name} for {type_name}"
else:
sig = f"impl {type_name}"
symbols.append(Symbol(
name=type_name,
symbol_type=SymbolType.TYPE,
file_path=file_path,
line_number=line_num,
signature=sig
))
return symbols
class JavaParser(LanguageParser):
"""Parser for Java files."""
CLASS_PATTERN = re.compile(
r'(?:public\s+)?(?:abstract\s+)?(?:final\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([^{]+))?',
re.MULTILINE
)
METHOD_PATTERN = re.compile(
r'(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\((.*?)\)',
re.MULTILINE
)
IMPORT_PATTERN = re.compile(
r'^import\s+(?:static\s+)?([^;]+);',
re.MULTILINE
)
def parse(self, content: str, file_path: str) -> List[Symbol]:
symbols = []
# Parse classes
for match in self.CLASS_PATTERN.finditer(content):
name = match.group(1)
extends = match.group(2)
implements = match.group(3)
line_num = content[:match.start()].count('\n') + 1
sig = f"class {name}"
if extends:
sig += f" extends {extends}"
if implements:
sig += f" implements {implements.strip()}"
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.CLASS,
file_path=file_path,
line_number=line_num,
signature=sig
))
# Parse methods
for match in self.METHOD_PATTERN.finditer(content):
name = match.group(1)
params = match.group(2)
line_num = content[:match.start()].count('\n') + 1
# Skip constructors and common keywords
if name in ('if', 'while', 'for', 'switch', 'catch'):
continue
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.METHOD,
file_path=file_path,
line_number=line_num,
signature=f"{name}({params})"
))
# Parse imports
for match in self.IMPORT_PATTERN.finditer(content):
import_path = match.group(1)
line_num = content[:match.start()].count('\n') + 1
# Get the last part of the import
name = import_path.split('.')[-1]
symbols.append(Symbol(
name=name,
symbol_type=SymbolType.IMPORT,
file_path=file_path,
line_number=line_num,
signature=f"import {import_path}"
))
return symbols
class SymbolIndexer:
"""Indexes code symbols from source files.
Supports multiple languages:
- Python (.py)
- JavaScript (.js, .jsx)
- TypeScript (.ts, .tsx)
- Go (.go)
- Rust (.rs)
- Java (.java)
"""
# Map file extensions to parsers
PARSERS = {
'.py': PythonParser,
'.js': JavaScriptParser,
'.jsx': JavaScriptParser,
'.ts': JavaScriptParser,
'.tsx': JavaScriptParser,
'.go': GoParser,
'.rs': RustParser,
'.java': JavaParser,
}
def __init__(self, workspace_path: str):
"""Initialize symbol indexer.
Args:
workspace_path: Root directory to index
"""
self.workspace_path = os.path.abspath(workspace_path)
self.symbols: Dict[str, List[Symbol]] = {} # file_path -> symbols
self.by_name: Dict[str, List[Symbol]] = {} # symbol_name -> symbols
self.by_type: Dict[SymbolType, List[Symbol]] = {} # type -> symbols
# Initialize parsers
self._parsers = {ext: parser() for ext, parser in self.PARSERS.items()}
def index_file(self, file_path: str) -> List[Symbol]:
"""Index symbols from a single file.
Args:
file_path: Path to the file
Returns:
List of extracted symbols
"""
ext = os.path.splitext(file_path)[1].lower()
parser = self._parsers.get(ext)
if not parser:
return []
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
rel_path = os.path.relpath(file_path, self.workspace_path)
symbols = parser.parse(content, rel_path)
# Store in indexes
self.symbols[rel_path] = symbols
for sym in symbols:
# By name
if sym.name not in self.by_name:
self.by_name[sym.name] = []
self.by_name[sym.name].append(sym)
# By type
if sym.symbol_type not in self.by_type:
self.by_type[sym.symbol_type] = []
self.by_type[sym.symbol_type].append(sym)
return symbols
except Exception as e:
logger.debug(f"Error indexing {file_path}: {e}")
return []
def index(self) -> int:
"""Index all supported files in workspace.
Returns:
Total number of symbols indexed
"""
total_symbols = 0
for root, dirs, files in os.walk(self.workspace_path):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.')]
for filename in files:
ext = os.path.splitext(filename)[1].lower()
if ext in self.PARSERS:
file_path = os.path.join(root, filename)
symbols = self.index_file(file_path)
total_symbols += len(symbols)
logger.info(f"Indexed {total_symbols} symbols from {len(self.symbols)} files")
return total_symbols
def find_by_name(self, name: str, exact: bool = False) -> List[Symbol]:
"""Find symbols by name.
Args:
name: Symbol name to search
exact: If True, match exact name
Returns:
List of matching symbols
"""
if exact:
return self.by_name.get(name, [])
results = []
name_lower = name.lower()
for sym_name, symbols in self.by_name.items():
if name_lower in sym_name.lower():
results.extend(symbols)
return results
def find_by_type(self, symbol_type: SymbolType) -> List[Symbol]:
"""Find symbols by type.
Args:
symbol_type: Type of symbol
Returns:
List of matching symbols
"""
return self.by_type.get(symbol_type, [])
def find_in_file(self, file_path: str) -> List[Symbol]:
"""Find all symbols in a file.
Args:
file_path: Path to the file
Returns:
List of symbols in the file
"""
return self.symbols.get(file_path, [])
def get_stats(self) -> Dict[str, Any]:
"""Get indexer statistics.
Returns:
Dictionary with stats
"""
type_counts = {t.value: len(syms) for t, syms in self.by_type.items()}
return {
"total_files": len(self.symbols),
"total_symbols": sum(len(s) for s in self.symbols.values()),
"by_type": type_counts
}
@@ -0,0 +1,325 @@
"""
Parallel execution engine for Fast Context.
Provides async parallel execution of search tools with:
- Configurable parallelism (default: 8 concurrent calls)
- Timeout handling per tool call
- Result aggregation
- Error isolation (one failure doesn't affect others)
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Any, Optional, Callable
import logging
import time
logger = logging.getLogger(__name__)
class ToolCallBatch:
"""Batch of tool calls to execute in parallel.
Attributes:
tasks: List of task dictionaries
max_size: Maximum batch size (default: 8)
"""
def __init__(self, max_size: int = 8):
"""Initialize batch.
Args:
max_size: Maximum number of tasks in batch
"""
self.tasks: List[Dict[str, Any]] = []
self.max_size = max_size
def add(self, tool_name: str, **kwargs) -> bool:
"""Add a tool call to the batch.
Args:
tool_name: Name of the tool to call
**kwargs: Tool arguments
Returns:
True if added, False if batch is full
"""
if self.is_full:
return False
self.tasks.append({
"tool": tool_name,
"args": kwargs
})
return True
@property
def is_full(self) -> bool:
"""Check if batch is at max capacity."""
return len(self.tasks) >= self.max_size
def clear(self) -> None:
"""Clear all tasks from batch."""
self.tasks.clear()
class ParallelExecutor:
"""Executes search tools in parallel.
Uses asyncio for concurrent execution with a thread pool
for CPU-bound operations.
Attributes:
max_parallel: Maximum concurrent executions
timeout: Timeout per tool call in seconds
"""
def __init__(
self,
max_parallel: int = 8,
timeout: float = 30.0
):
"""Initialize executor.
Args:
max_parallel: Maximum concurrent tool calls
timeout: Timeout per tool call in seconds
"""
self.max_parallel = max_parallel
self.timeout = timeout
self._executor: Optional[ThreadPoolExecutor] = None
def _get_executor(self) -> ThreadPoolExecutor:
"""Get or create thread pool executor."""
if self._executor is None:
self._executor = ThreadPoolExecutor(max_workers=self.max_parallel)
return self._executor
def _get_tool_function(self, tool_name: str) -> Optional[Callable]:
"""Get tool function by name.
Args:
tool_name: Name of the tool
Returns:
Tool function or None if not found
"""
# Import here to avoid circular imports
from praisonaiagents.context.fast.search_tools import (
grep_search,
glob_search,
read_file,
list_directory
)
tools = {
"grep_search": grep_search,
"glob_search": glob_search,
"read_file": read_file,
"list_directory": list_directory
}
return tools.get(tool_name)
async def _execute_single(
self,
task: Dict[str, Any],
loop: asyncio.AbstractEventLoop
) -> Any:
"""Execute a single tool call.
Args:
task: Task dictionary with 'tool' and 'args'
loop: Event loop for thread execution
Returns:
Tool result or error dictionary
"""
tool_name = task.get("tool")
args = task.get("args", {})
tool_func = self._get_tool_function(tool_name)
if tool_func is None:
return {"error": f"Unknown tool: {tool_name}"}
try:
# Run in thread pool to avoid blocking
executor = self._get_executor()
result = await asyncio.wait_for(
loop.run_in_executor(executor, lambda: tool_func(**args)),
timeout=self.timeout
)
return result
except asyncio.TimeoutError:
return {"error": f"Timeout executing {tool_name}", "timeout": True}
except Exception as e:
logger.debug(f"Error executing {tool_name}: {e}")
return {"error": str(e)}
async def execute(self, tasks: List[Dict[str, Any]]) -> List[Any]:
"""Execute multiple tool calls in parallel.
Args:
tasks: List of task dictionaries, each with 'tool' and 'args'
Returns:
List of results in same order as tasks
"""
if not tasks:
return []
loop = asyncio.get_event_loop()
# Create semaphore to limit concurrency
semaphore = asyncio.Semaphore(self.max_parallel)
async def bounded_execute(task: Dict[str, Any]) -> Any:
async with semaphore:
return await self._execute_single(task, loop)
# Execute all tasks concurrently
results = await asyncio.gather(
*[bounded_execute(task) for task in tasks],
return_exceptions=True
)
# Convert exceptions to error dicts
processed_results = []
for result in results:
if isinstance(result, Exception):
processed_results.append({"error": str(result)})
else:
processed_results.append(result)
return processed_results
def execute_sync(self, tasks: List[Dict[str, Any]]) -> List[Any]:
"""Synchronous wrapper for execute.
Args:
tasks: List of task dictionaries
Returns:
List of results
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# If already in async context, create new loop
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, self.execute(tasks))
return future.result()
else:
return loop.run_until_complete(self.execute(tasks))
except RuntimeError:
# No event loop, create one
return asyncio.run(self.execute(tasks))
def close(self) -> None:
"""Close the executor and release resources."""
if self._executor is not None:
self._executor.shutdown(wait=False)
self._executor = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
class ParallelSearchCoordinator:
"""Coordinates parallel search operations across multiple turns.
Implements the Fast Context search strategy:
- Up to 8 parallel tool calls per turn
- Maximum 4 turns
- Aggregates results across turns
"""
def __init__(
self,
max_parallel: int = 8,
max_turns: int = 4,
timeout: float = 30.0
):
"""Initialize coordinator.
Args:
max_parallel: Max parallel calls per turn
max_turns: Maximum number of turns
timeout: Timeout per tool call
"""
self.max_parallel = max_parallel
self.max_turns = max_turns
self.timeout = timeout
self.executor = ParallelExecutor(max_parallel, timeout)
# Track execution stats
self.total_tool_calls = 0
self.turns_used = 0
self.start_time: Optional[float] = None
self.end_time: Optional[float] = None
def start(self) -> None:
"""Start timing the search."""
self.start_time = time.perf_counter()
self.total_tool_calls = 0
self.turns_used = 0
def stop(self) -> None:
"""Stop timing the search."""
self.end_time = time.perf_counter()
@property
def elapsed_ms(self) -> int:
"""Get elapsed time in milliseconds."""
if self.start_time is None:
return 0
end = self.end_time or time.perf_counter()
return int((end - self.start_time) * 1000)
async def execute_turn(self, batch: ToolCallBatch) -> List[Any]:
"""Execute a single turn of parallel tool calls.
Args:
batch: Batch of tool calls
Returns:
List of results
"""
if self.turns_used >= self.max_turns:
logger.warning("Maximum turns reached")
return []
self.turns_used += 1
self.total_tool_calls += len(batch.tasks)
results = await self.executor.execute(batch.tasks)
return results
def execute_turn_sync(self, batch: ToolCallBatch) -> List[Any]:
"""Synchronous wrapper for execute_turn.
Args:
batch: Batch of tool calls
Returns:
List of results
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, self.execute_turn(batch))
return future.result()
else:
return loop.run_until_complete(self.execute_turn(batch))
except RuntimeError:
return asyncio.run(self.execute_turn(batch))
def close(self) -> None:
"""Close resources."""
self.executor.close()
@@ -0,0 +1,210 @@
"""
Result classes for Fast Context.
These dataclasses represent the output of Fast Context searches,
providing structured file and line range information.
"""
from dataclasses import dataclass, field
from typing import List, Optional
import time
@dataclass
class LineRange:
"""Represents a range of lines in a file.
Attributes:
start: Starting line number (1-indexed)
end: Ending line number (1-indexed, inclusive)
content: Optional content of the lines
relevance_score: Score indicating relevance (0.0-1.0)
"""
start: int
end: int
content: Optional[str] = None
relevance_score: float = 1.0
def __post_init__(self):
if self.start < 1:
self.start = 1
if self.end < self.start:
self.end = self.start
@property
def line_count(self) -> int:
"""Number of lines in this range."""
return self.end - self.start + 1
def overlaps(self, other: "LineRange") -> bool:
"""Check if this range overlaps with another."""
return not (self.end < other.start or self.start > other.end)
def merge(self, other: "LineRange") -> "LineRange":
"""Merge with another overlapping range."""
if not self.overlaps(other):
raise ValueError("Cannot merge non-overlapping ranges")
return LineRange(
start=min(self.start, other.start),
end=max(self.end, other.end),
content=None, # Content needs to be re-fetched
relevance_score=max(self.relevance_score, other.relevance_score)
)
@dataclass
class FileMatch:
"""Represents a file match from Fast Context search.
Attributes:
path: Absolute or relative path to the file
line_ranges: List of relevant line ranges in the file
relevance_score: Overall relevance score (0.0-1.0)
match_count: Number of pattern matches found
"""
path: str
line_ranges: List[LineRange] = field(default_factory=list)
relevance_score: float = 1.0
match_count: int = 0
def add_line_range(self, line_range: LineRange) -> None:
"""Add a line range, merging with existing overlapping ranges."""
# Find overlapping ranges
overlapping = []
non_overlapping = []
for existing in self.line_ranges:
if existing.overlaps(line_range):
overlapping.append(existing)
else:
non_overlapping.append(existing)
# Merge all overlapping ranges
merged = line_range
for overlap in overlapping:
merged = merged.merge(overlap)
self.line_ranges = non_overlapping + [merged]
self.line_ranges.sort(key=lambda r: r.start)
@property
def total_lines(self) -> int:
"""Total number of lines across all ranges."""
return sum(r.line_count for r in self.line_ranges)
@dataclass
class FastContextResult:
"""Result of a Fast Context search operation.
Attributes:
files: List of matching files with line ranges
query: The original search query
search_time_ms: Time taken for the search in milliseconds
turns_used: Number of turns used by the subagent
total_tool_calls: Total number of tool calls made
from_cache: Whether result was retrieved from cache
"""
files: List[FileMatch] = field(default_factory=list)
query: str = ""
search_time_ms: int = 0
turns_used: int = 0
total_tool_calls: int = 0
from_cache: bool = False
_start_time: float = field(default=0.0, repr=False)
def start_timer(self) -> None:
"""Start the search timer."""
self._start_time = time.perf_counter()
def stop_timer(self) -> None:
"""Stop the timer and record elapsed time."""
if self._start_time > 0:
elapsed = time.perf_counter() - self._start_time
self.search_time_ms = int(elapsed * 1000)
def add_file(self, file_match: FileMatch) -> None:
"""Add a file match, merging with existing if same path."""
for existing in self.files:
if existing.path == file_match.path:
# Merge line ranges
for lr in file_match.line_ranges:
existing.add_line_range(lr)
existing.relevance_score = max(
existing.relevance_score,
file_match.relevance_score
)
existing.match_count += file_match.match_count
return
self.files.append(file_match)
def sort_by_relevance(self) -> None:
"""Sort files by relevance score (highest first)."""
self.files.sort(key=lambda f: f.relevance_score, reverse=True)
@property
def total_files(self) -> int:
"""Total number of files found."""
return len(self.files)
@property
def total_lines(self) -> int:
"""Total number of lines across all files."""
return sum(f.total_lines for f in self.files)
def to_context_string(self, max_files: int = 10, include_content: bool = True) -> str:
"""Convert result to a context string for the main agent.
Args:
max_files: Maximum number of files to include
include_content: Whether to include line content
Returns:
Formatted context string
"""
if not self.files:
return "No relevant files found."
lines = [f"Found {self.total_files} relevant file(s):"]
for i, file_match in enumerate(self.files[:max_files]):
lines.append(f"\n## {file_match.path}")
if file_match.line_ranges:
for lr in file_match.line_ranges:
lines.append(f" Lines {lr.start}-{lr.end}")
if include_content and lr.content:
# Indent content
content_lines = lr.content.split('\n')
for cl in content_lines:
lines.append(f" {cl}")
if len(self.files) > max_files:
lines.append(f"\n... and {len(self.files) - max_files} more files")
return '\n'.join(lines)
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"files": [
{
"path": f.path,
"relevance_score": f.relevance_score,
"match_count": f.match_count,
"line_ranges": [
{
"start": lr.start,
"end": lr.end,
"relevance_score": lr.relevance_score
}
for lr in f.line_ranges
]
}
for f in self.files
],
"query": self.query,
"search_time_ms": self.search_time_ms,
"turns_used": self.turns_used,
"total_tool_calls": self.total_tool_calls,
"from_cache": self.from_cache
}
@@ -0,0 +1,368 @@
"""
Search Strategy for Fast Context.
Orchestrates multi-turn search with:
- Turn 1-3: Exploration (grep, glob, directory listing)
- Turn 4: Final answer (file list + line ranges)
- Early termination if sufficient context found
"""
import logging
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Set
from enum import Enum
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
logger = logging.getLogger(__name__)
class SearchPhase(Enum):
"""Phases of the search strategy."""
DISCOVERY = "discovery" # Turn 1: Discover structure
EXPLORATION = "exploration" # Turn 2-3: Deep search
REFINEMENT = "refinement" # Turn 4: Final answer
@dataclass
class SearchState:
"""Tracks the state of a multi-turn search.
Attributes:
query: Original search query
current_turn: Current turn number (1-indexed)
max_turns: Maximum turns allowed
phase: Current search phase
discovered_files: Files found so far
explored_paths: Paths already explored
result: Accumulated search result
should_terminate: Whether to terminate early
"""
query: str
current_turn: int = 0
max_turns: int = 4
phase: SearchPhase = SearchPhase.DISCOVERY
discovered_files: Set[str] = field(default_factory=set)
explored_paths: Set[str] = field(default_factory=set)
result: FastContextResult = field(default_factory=FastContextResult)
should_terminate: bool = False
# Thresholds for early termination
min_files_for_termination: int = 5
max_files_before_refinement: int = 20
def advance_turn(self) -> None:
"""Advance to the next turn and update phase."""
self.current_turn += 1
if self.current_turn == 1:
self.phase = SearchPhase.DISCOVERY
elif self.current_turn < self.max_turns:
self.phase = SearchPhase.EXPLORATION
else:
self.phase = SearchPhase.REFINEMENT
def check_early_termination(self) -> bool:
"""Check if search should terminate early.
Returns:
True if search should terminate
"""
# Terminate if we have enough high-quality results
if len(self.discovered_files) >= self.min_files_for_termination:
high_relevance = sum(
1 for f in self.result.files
if f.relevance_score >= 0.8
)
if high_relevance >= 3:
self.should_terminate = True
return True
# Terminate if we've explored everything
if self.current_turn >= 2 and not self._has_unexplored_paths():
self.should_terminate = True
return True
return False
def _has_unexplored_paths(self) -> bool:
"""Check if there are unexplored paths."""
# Simple heuristic: if we found files but haven't explored many paths
return len(self.discovered_files) > len(self.explored_paths) * 2
class SearchStrategy:
"""Orchestrates multi-turn search strategy.
Implements the Fast Context search approach:
- Turn 1: Discovery - understand codebase structure
- Turn 2-3: Exploration - deep search for relevant code
- Turn 4: Refinement - finalize results
Supports early termination when sufficient context is found.
"""
def __init__(
self,
workspace_path: str,
max_turns: int = 4,
max_parallel: int = 8
):
"""Initialize search strategy.
Args:
workspace_path: Root directory for searches
max_turns: Maximum search turns
max_parallel: Maximum parallel tool calls per turn
"""
self.workspace_path = workspace_path
self.max_turns = max_turns
self.max_parallel = max_parallel
def create_state(self, query: str) -> SearchState:
"""Create a new search state.
Args:
query: Search query
Returns:
New SearchState instance
"""
state = SearchState(query=query, max_turns=self.max_turns)
state.result.query = query
return state
def plan_discovery_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan the discovery turn (Turn 1).
Focus on understanding codebase structure:
- List root directory
- Find common file patterns
- Initial grep for query terms
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
query = state.query
# List root directory
batch.add("list_directory", dir_path=self.workspace_path, recursive=False)
# Find Python files (most common)
batch.add("glob_search", search_path=self.workspace_path, pattern="**/*.py", max_results=20)
# Initial grep for query terms
query_words = query.lower().split()
for word in query_words[:3]: # First 3 words
if len(word) >= 3: # Skip short words
batch.add("grep_search", search_path=self.workspace_path, pattern=word, max_results=10)
if batch.is_full:
break
return batch
def plan_exploration_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan an exploration turn (Turn 2-3).
Deep search based on discovery results:
- Search in discovered directories
- Read promising files
- Follow references
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
# Read top files found so far
for file_match in state.result.files[:4]:
if file_match.path not in state.explored_paths:
batch.add("read_file", filepath=file_match.path, context_lines=5)
state.explored_paths.add(file_match.path)
if batch.is_full:
break
# Search in unexplored directories
unexplored_dirs = self._find_unexplored_dirs(state)
for dir_path in unexplored_dirs[:2]:
batch.add("list_directory", dir_path=dir_path, recursive=True, max_depth=2)
if batch.is_full:
break
# Additional grep searches with refined patterns
if not batch.is_full:
refined_patterns = self._generate_refined_patterns(state)
for pattern in refined_patterns[:2]:
batch.add("grep_search", search_path=self.workspace_path, pattern=pattern, max_results=15)
if batch.is_full:
break
return batch
def plan_refinement_turn(self, state: SearchState) -> ToolCallBatch:
"""Plan the refinement turn (Turn 4).
Finalize results:
- Read remaining important files
- Get specific line ranges
Args:
state: Current search state
Returns:
Batch of tool calls to execute
"""
batch = ToolCallBatch(max_size=self.max_parallel)
# Read top files that haven't been fully explored
for file_match in state.result.files[:self.max_parallel]:
if file_match.path not in state.explored_paths:
# Read specific line ranges if available
if file_match.line_ranges:
for lr in file_match.line_ranges[:2]:
batch.add(
"read_file",
filepath=file_match.path,
start_line=max(1, lr.start - 5),
end_line=lr.end + 5
)
if batch.is_full:
break
else:
batch.add("read_file", filepath=file_match.path)
state.explored_paths.add(file_match.path)
if batch.is_full:
break
return batch
def plan_next_turn(self, state: SearchState) -> Optional[ToolCallBatch]:
"""Plan the next turn based on current state.
Args:
state: Current search state
Returns:
Batch of tool calls, or None if search should end
"""
state.advance_turn()
# Check for early termination
if state.should_terminate or state.current_turn > state.max_turns:
return None
if state.check_early_termination():
return None
# Plan based on phase
if state.phase == SearchPhase.DISCOVERY:
return self.plan_discovery_turn(state)
elif state.phase == SearchPhase.EXPLORATION:
return self.plan_exploration_turn(state)
else: # REFINEMENT
return self.plan_refinement_turn(state)
def process_results(
self,
state: SearchState,
tool_results: List[Any]
) -> None:
"""Process results from a turn and update state.
Args:
state: Current search state
tool_results: Results from tool execution
"""
for result in tool_results:
if isinstance(result, list):
# Grep or glob results
for item in result:
if isinstance(item, dict):
path = item.get("path", "")
if path:
state.discovered_files.add(path)
# Create or update file match
file_match = FileMatch(
path=path,
relevance_score=0.8,
match_count=1
)
# Add line range if available
line_num = item.get("line_number")
if line_num:
file_match.add_line_range(LineRange(
start=max(1, line_num - 2),
end=line_num + 2,
content=item.get("context"),
relevance_score=0.8
))
state.result.add_file(file_match)
elif isinstance(result, dict):
# Read file or list directory result
if result.get("success"):
path = result.get("path", "")
if path:
state.explored_paths.add(path)
# Handle directory listing
entries = result.get("entries", [])
for entry in entries:
if not entry.get("is_dir"):
state.discovered_files.add(entry.get("path", ""))
def _find_unexplored_dirs(self, state: SearchState) -> List[str]:
"""Find directories that haven't been explored.
Args:
state: Current search state
Returns:
List of unexplored directory paths
"""
import os
# Extract unique directories from discovered files
dirs = set()
for file_path in state.discovered_files:
dir_path = os.path.dirname(file_path)
if dir_path and dir_path not in state.explored_paths:
dirs.add(dir_path)
return list(dirs)[:5] # Limit to 5
def _generate_refined_patterns(self, state: SearchState) -> List[str]:
"""Generate refined search patterns based on results.
Args:
state: Current search state
Returns:
List of refined search patterns
"""
patterns = []
query_words = state.query.lower().split()
# Combine query words
if len(query_words) >= 2:
patterns.append(f"{query_words[0]}.*{query_words[1]}")
# Add common code patterns
for word in query_words[:2]:
if len(word) >= 3:
patterns.append(f"def {word}")
patterns.append(f"class {word}")
return patterns[:4]
@@ -0,0 +1,636 @@
"""
Search tools for Fast Context.
Provides cross-platform compatible search tools:
- grep_search: Pattern search in files
- glob_search: File pattern matching
- read_file: Read file contents with line range support
- list_directory: List directory contents
These tools are designed to be fast, safe, and cross-platform compatible.
"""
import os
import re
import fnmatch
from pathlib import Path
from typing import List, Dict, Any, Optional, Set
import logging
logger = logging.getLogger(__name__)
def _load_gitignore_patterns(root_path: str) -> Set[str]:
"""Load patterns from .gitignore file.
Args:
root_path: Root directory to search for .gitignore
Returns:
Set of gitignore patterns
"""
patterns = set()
gitignore_path = Path(root_path) / ".gitignore"
if gitignore_path.exists():
try:
with open(gitignore_path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith('#'):
patterns.add(line)
except Exception:
pass
# Also check for .praisonignore
praisonignore_path = Path(root_path) / ".praisonignore"
if praisonignore_path.exists():
try:
with open(praisonignore_path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
patterns.add(line)
except Exception:
pass
return patterns
def _should_ignore(path: str, root_path: str, patterns: Set[str]) -> bool:
"""Check if a path should be ignored based on gitignore patterns.
Args:
path: Path to check
root_path: Root directory
patterns: Set of gitignore patterns
Returns:
True if path should be ignored
"""
if not patterns:
return False
rel_path = os.path.relpath(path, root_path)
path_parts = Path(rel_path).parts
for pattern in patterns:
# Handle directory patterns (ending with /)
if pattern.endswith('/'):
dir_pattern = pattern.rstrip('/')
if any(fnmatch.fnmatch(part, dir_pattern) for part in path_parts):
return True
# Handle ** patterns
elif '**' in pattern:
# Convert gitignore pattern to fnmatch pattern
fnmatch_pattern = pattern.replace('**/', '*').replace('**', '*')
if fnmatch.fnmatch(rel_path, fnmatch_pattern):
return True
# Handle simple patterns
else:
# Check against filename
if fnmatch.fnmatch(os.path.basename(path), pattern):
return True
# Check against relative path
if fnmatch.fnmatch(rel_path, pattern):
return True
# Check if any path component matches
if any(fnmatch.fnmatch(part, pattern) for part in path_parts):
return True
return False
def _is_binary_file(filepath: str) -> bool:
"""Check if a file is binary.
Args:
filepath: Path to the file
Returns:
True if file appears to be binary
"""
try:
with open(filepath, 'rb') as f:
chunk = f.read(8192)
# Check for null bytes (common in binary files)
if b'\x00' in chunk:
return True
# Check for high ratio of non-text characters
text_chars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7f})
non_text = sum(1 for byte in chunk if byte not in text_chars)
if len(chunk) > 0 and non_text / len(chunk) > 0.3:
return True
except Exception:
return True
return False
def grep_search(
search_path: str,
pattern: str,
is_regex: bool = False,
case_sensitive: bool = False,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None,
max_results: int = 100,
context_lines: int = 0,
respect_gitignore: bool = True
) -> List[Dict[str, Any]]:
"""Search for pattern in files.
Args:
search_path: Directory or file to search
pattern: Search pattern (string or regex)
is_regex: If True, treat pattern as regex
case_sensitive: If True, search is case sensitive
include_patterns: Glob patterns to include (e.g., ["*.py"])
exclude_patterns: Glob patterns to exclude (e.g., ["**/test_*"])
max_results: Maximum number of results to return
context_lines: Number of context lines before/after match
respect_gitignore: If True, respect .gitignore patterns
Returns:
List of match dictionaries with path, line_number, content, context
"""
results = []
search_path = os.path.abspath(search_path)
# Compile pattern
if is_regex:
flags = 0 if case_sensitive else re.IGNORECASE
try:
regex = re.compile(pattern, flags)
except re.error as e:
logger.warning(f"Invalid regex pattern: {e}")
return []
else:
if case_sensitive:
regex = re.compile(re.escape(pattern))
else:
regex = re.compile(re.escape(pattern), re.IGNORECASE)
# Load gitignore patterns
gitignore_patterns = set()
if respect_gitignore:
gitignore_patterns = _load_gitignore_patterns(search_path)
# Get files to search
if os.path.isfile(search_path):
files_to_search = [search_path]
else:
files_to_search = []
for root, dirs, files in os.walk(search_path):
# Filter directories in-place to skip ignored ones
if respect_gitignore:
dirs[:] = [d for d in dirs if not _should_ignore(
os.path.join(root, d), search_path, gitignore_patterns
)]
for filename in files:
filepath = os.path.join(root, filename)
# Check gitignore
if respect_gitignore and _should_ignore(filepath, search_path, gitignore_patterns):
continue
# Check include patterns
if include_patterns:
rel_path = os.path.relpath(filepath, search_path)
if not any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(filename, p)
for p in include_patterns):
continue
# Check exclude patterns
if exclude_patterns:
rel_path = os.path.relpath(filepath, search_path)
if any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(filename, p)
for p in exclude_patterns):
continue
files_to_search.append(filepath)
# Search files
for filepath in files_to_search:
if len(results) >= max_results:
break
# Skip binary files
if _is_binary_file(filepath):
continue
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
for i, line in enumerate(lines):
if len(results) >= max_results:
break
if regex.search(line):
# Get context
start_ctx = max(0, i - context_lines)
end_ctx = min(len(lines), i + context_lines + 1)
context = ''.join(lines[start_ctx:end_ctx])
results.append({
"path": os.path.relpath(filepath, search_path),
"absolute_path": filepath,
"line_number": i + 1, # 1-indexed
"content": line.rstrip('\n\r'),
"context": context.rstrip('\n\r') if context_lines > 0 else None,
"context_start": start_ctx + 1 if context_lines > 0 else None,
"context_end": end_ctx if context_lines > 0 else None
})
except Exception as e:
logger.debug(f"Error reading {filepath}: {e}")
continue
return results
def glob_search(
search_path: str,
pattern: str,
max_results: int = 100,
include_dirs: bool = False,
respect_gitignore: bool = True
) -> List[Dict[str, Any]]:
"""Search for files matching glob pattern.
Args:
search_path: Directory to search
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
max_results: Maximum number of results
include_dirs: If True, include directories in results
respect_gitignore: If True, respect .gitignore patterns
Returns:
List of file info dictionaries with path, size, is_dir
"""
results = []
search_path = os.path.abspath(search_path)
# Load gitignore patterns
gitignore_patterns = set()
if respect_gitignore:
gitignore_patterns = _load_gitignore_patterns(search_path)
# Use pathlib for glob
root = Path(search_path)
try:
for match in root.glob(pattern):
if len(results) >= max_results:
break
# Check gitignore
if respect_gitignore and _should_ignore(str(match), search_path, gitignore_patterns):
continue
# Skip directories unless requested
if match.is_dir() and not include_dirs:
continue
# Skip binary files for file results
if match.is_file() and _is_binary_file(str(match)):
continue
try:
stat = match.stat()
results.append({
"path": str(match.relative_to(root)),
"absolute_path": str(match),
"size": stat.st_size if match.is_file() else None,
"is_dir": match.is_dir(),
"modified": stat.st_mtime
})
except Exception:
continue
except Exception as e:
logger.debug(f"Error in glob search: {e}")
return results
def read_file(
filepath: str,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
context_lines: int = 0,
max_lines: int = 500
) -> Dict[str, Any]:
"""Read file contents with optional line range.
Args:
filepath: Path to the file
start_line: Starting line (1-indexed, inclusive)
end_line: Ending line (1-indexed, inclusive)
context_lines: Additional context lines before/after range
max_lines: Maximum lines to return
Returns:
Dictionary with content, line info, and metadata
"""
filepath = os.path.abspath(filepath)
if not os.path.exists(filepath):
return {
"success": False,
"error": f"File not found: {filepath}",
"path": filepath
}
if os.path.isdir(filepath):
return {
"success": False,
"error": f"Path is a directory: {filepath}",
"path": filepath
}
if _is_binary_file(filepath):
return {
"success": False,
"error": f"Binary file: {filepath}",
"path": filepath
}
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
total_lines = len(lines)
# Determine line range
if start_line is None:
start_line = 1
if end_line is None:
end_line = total_lines
# Apply context
actual_start = max(1, start_line - context_lines)
actual_end = min(total_lines, end_line + context_lines)
# Limit lines
if actual_end - actual_start + 1 > max_lines:
actual_end = actual_start + max_lines - 1
# Extract content (convert to 0-indexed)
content_lines = lines[actual_start - 1:actual_end]
content = ''.join(content_lines)
return {
"success": True,
"path": filepath,
"content": content.rstrip('\n'),
"start_line": actual_start,
"end_line": actual_end,
"total_lines": total_lines,
"lines_returned": len(content_lines)
}
except Exception as e:
return {
"success": False,
"error": str(e),
"path": filepath
}
def list_directory(
dir_path: str,
recursive: bool = False,
max_depth: int = 10,
max_entries: int = 500,
include_hidden: bool = False,
respect_gitignore: bool = True
) -> Dict[str, Any]:
"""List directory contents.
Args:
dir_path: Directory path
recursive: If True, list recursively
max_depth: Maximum recursion depth
max_entries: Maximum entries to return
include_hidden: If True, include hidden files/dirs
respect_gitignore: If True, respect .gitignore patterns
Returns:
Dictionary with entries list and metadata
"""
dir_path = os.path.abspath(dir_path)
if not os.path.exists(dir_path):
return {
"success": False,
"error": f"Directory not found: {dir_path}",
"path": dir_path
}
if not os.path.isdir(dir_path):
return {
"success": False,
"error": f"Not a directory: {dir_path}",
"path": dir_path
}
# Load gitignore patterns
gitignore_patterns = set()
if respect_gitignore:
gitignore_patterns = _load_gitignore_patterns(dir_path)
entries = []
def _list_dir(path: str, depth: int):
if depth > max_depth or len(entries) >= max_entries:
return
try:
for entry in os.scandir(path):
if len(entries) >= max_entries:
break
# Skip hidden files unless requested
if not include_hidden and entry.name.startswith('.'):
continue
# Check gitignore
if respect_gitignore and _should_ignore(entry.path, dir_path, gitignore_patterns):
continue
try:
stat = entry.stat()
entries.append({
"name": entry.name,
"path": os.path.relpath(entry.path, dir_path),
"absolute_path": entry.path,
"is_dir": entry.is_dir(),
"size": stat.st_size if entry.is_file() else None,
"modified": stat.st_mtime
})
except Exception:
continue
# Recurse into directories
if recursive and entry.is_dir():
_list_dir(entry.path, depth + 1)
except PermissionError:
pass
except Exception as e:
logger.debug(f"Error listing {path}: {e}")
_list_dir(dir_path, 0)
# Sort entries: directories first, then by name
entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
return {
"success": True,
"path": dir_path,
"entries": entries,
"total_entries": len(entries),
"truncated": len(entries) >= max_entries
}
# Tool definitions for LLM function calling
FAST_CONTEXT_TOOLS = [
{
"name": "grep_search",
"description": "Search for a pattern in files within a directory. Returns matching lines with file paths and line numbers.",
"parameters": {
"type": "object",
"properties": {
"search_path": {
"type": "string",
"description": "Directory or file path to search"
},
"pattern": {
"type": "string",
"description": "Search pattern (text or regex)"
},
"is_regex": {
"type": "boolean",
"description": "If true, treat pattern as regex",
"default": False
},
"case_sensitive": {
"type": "boolean",
"description": "If true, search is case sensitive",
"default": False
},
"include_patterns": {
"type": "array",
"items": {"type": "string"},
"description": "Glob patterns to include (e.g., ['*.py'])"
},
"max_results": {
"type": "integer",
"description": "Maximum results to return",
"default": 50
}
},
"required": ["search_path", "pattern"]
}
},
{
"name": "glob_search",
"description": "Find files matching a glob pattern. Returns file paths and metadata.",
"parameters": {
"type": "object",
"properties": {
"search_path": {
"type": "string",
"description": "Directory to search"
},
"pattern": {
"type": "string",
"description": "Glob pattern (e.g., '**/*.py', 'src/*.js')"
},
"max_results": {
"type": "integer",
"description": "Maximum results to return",
"default": 50
}
},
"required": ["search_path", "pattern"]
}
},
{
"name": "read_file",
"description": "Read contents of a file, optionally with specific line range.",
"parameters": {
"type": "object",
"properties": {
"filepath": {
"type": "string",
"description": "Path to the file"
},
"start_line": {
"type": "integer",
"description": "Starting line number (1-indexed)"
},
"end_line": {
"type": "integer",
"description": "Ending line number (1-indexed)"
},
"context_lines": {
"type": "integer",
"description": "Additional context lines",
"default": 0
}
},
"required": ["filepath"]
}
},
{
"name": "list_directory",
"description": "List contents of a directory.",
"parameters": {
"type": "object",
"properties": {
"dir_path": {
"type": "string",
"description": "Directory path"
},
"recursive": {
"type": "boolean",
"description": "If true, list recursively",
"default": False
},
"max_depth": {
"type": "integer",
"description": "Maximum recursion depth",
"default": 3
}
},
"required": ["dir_path"]
}
}
]
def execute_tool(tool_name: str, **kwargs) -> Dict[str, Any]:
"""Execute a search tool by name.
Args:
tool_name: Name of the tool to execute
**kwargs: Tool arguments
Returns:
Tool result dictionary
"""
tools = {
"grep_search": grep_search,
"glob_search": glob_search,
"read_file": read_file,
"list_directory": list_directory
}
if tool_name not in tools:
return {"error": f"Unknown tool: {tool_name}"}
try:
return tools[tool_name](**kwargs)
except Exception as e:
return {"error": str(e)}
@@ -15,6 +15,8 @@ This module provides memory management capabilities including:
- Auto-generated memories (like Windsurf Cascade)
- Workflows (like Windsurf)
- Hooks (like Windsurf Cascade Hooks)
- Docs management (like Cursor docs)
- MCP config management (like Cursor .cursor/mcp/)
Memory Providers:
- FileMemory: Zero-dependency JSON file-based storage (default)
@@ -23,10 +25,14 @@ Memory Providers:
- AutoMemory: Automatic memory extraction from conversations
- WorkflowManager: Multi-step workflow execution
- HooksManager: Pre/post operation hooks
- DocsManager: Documentation context management
- MCPConfigManager: MCP server configuration management
"""
from .file_memory import FileMemory, create_memory
from .rules_manager import RulesManager, Rule, create_rules_manager
from .docs_manager import DocsManager, Doc
from .mcp_config import MCPConfigManager, MCPConfig
# Lazy imports for optional modules to avoid dependency issues and improve startup time
def __getattr__(name):
@@ -42,17 +48,58 @@ def __getattr__(name):
if name == "create_auto_memory":
from .auto_memory import create_auto_memory
return create_auto_memory
# Workflows - now in dedicated module, re-export for backward compatibility
if name == "WorkflowManager":
from .workflows import WorkflowManager
from ..workflows import WorkflowManager
return WorkflowManager
if name == "Workflow":
from .workflows import Workflow
from ..workflows import Workflow
return Workflow
if name == "WorkflowStep":
from .workflows import WorkflowStep
from ..workflows import WorkflowStep
return WorkflowStep
if name == "WorkflowContext":
from ..workflows import WorkflowContext
return WorkflowContext
if name == "StepResult":
from ..workflows import StepResult
return StepResult
if name == "Pipeline":
from ..workflows import Pipeline
return Pipeline
if name == "Route":
from ..workflows import Route
return Route
if name == "Parallel":
from ..workflows import Parallel
return Parallel
if name == "Loop":
from ..workflows import Loop
return Loop
if name == "Repeat":
from ..workflows import Repeat
return Repeat
if name == "route":
from ..workflows import route
return route
if name == "parallel":
from ..workflows import parallel
return parallel
if name == "loop":
from ..workflows import loop
return loop
if name == "repeat":
from ..workflows import repeat
return repeat
# Backward compatibility aliases
if name == "StepInput":
from ..workflows import WorkflowContext
return WorkflowContext
if name == "StepOutput":
from ..workflows import StepResult
return StepResult
if name == "create_workflow_manager":
from .workflows import create_workflow_manager
from ..workflows.workflows import create_workflow_manager
return create_workflow_manager
if name == "HooksManager":
from .hooks import HooksManager
@@ -63,6 +110,7 @@ def __getattr__(name):
if name == "create_hooks_manager":
from .hooks import create_hooks_manager
return create_hooks_manager
# DocsManager and MCPConfigManager are already imported at module level
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
@@ -78,13 +126,31 @@ __all__ = [
"AutoMemory",
"AutoMemoryExtractor",
"create_auto_memory",
# Workflows
# Workflows (re-exported from praisonaiagents.workflows for backward compatibility)
"WorkflowManager",
"Workflow",
"Pipeline",
"WorkflowStep",
"WorkflowContext",
"StepResult",
"create_workflow_manager",
# Workflow patterns
"Route",
"Parallel",
"Loop",
"Repeat",
"route",
"parallel",
"loop",
"repeat",
# Hooks
"HooksManager",
"HookResult",
"create_hooks_manager",
# Docs management
"DocsManager",
"Doc",
# MCP config management
"MCPConfigManager",
"MCPConfig",
]
@@ -0,0 +1,431 @@
"""
Docs Manager for PraisonAI Agents.
Provides reusable documentation context support similar to:
- Cursor Notepads (deprecated in favor of docs)
- Cursor .cursor/docs/
- Project documentation for AI context
Features:
- Auto-discovery of docs from .praison/docs/
- Support for markdown, text, and code files
- @doc mentions for including specific docs
- Priority ordering for docs
- Character limits for context window management
Storage Structure:
.praison/docs/
├── project-overview.md # Project overview
├── architecture.md # Architecture decisions
├── api-reference.md # API documentation
└── coding-standards.md # Coding guidelines
Doc File Format (YAML frontmatter + Markdown):
---
description: Project architecture overview
priority: 10
tags: [architecture, design]
---
# Architecture Overview
This project uses...
"""
import os
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Literal
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class Doc:
"""A single documentation file with metadata and content."""
name: str
content: str
description: str = ""
priority: int = 0
tags: List[str] = field(default_factory=list)
file_path: Optional[str] = None
def matches_tag(self, tag: str) -> bool:
"""Check if this doc has a specific tag."""
return tag.lower() in [t.lower() for t in self.tags]
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"content": self.content,
"description": self.description,
"priority": self.priority,
"tags": self.tags,
"file_path": self.file_path
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Doc':
return cls(**data)
class DocsManager:
"""
Manages documentation context for AI agents.
Provides:
- Auto-discovery of docs from .praison/docs/
- Global docs from ~/.praison/docs/
- @doc mentions for including specific docs
- Priority-based ordering
"""
DOCS_DIR_NAME = ".praison/docs"
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
# Maximum characters per doc (context window management)
MAX_DOC_CHARS = 12000
def __init__(
self,
workspace_path: Optional[str] = None,
global_docs_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize DocsManager.
Args:
workspace_path: Path to workspace/project root
global_docs_path: Path to global docs (default: ~/.praison/docs)
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praison" / "docs"
self.verbose = verbose
self._docs: Dict[str, Doc] = {}
self._load_all_docs()
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _parse_frontmatter(self, content: str) -> tuple:
"""
Parse YAML frontmatter from content.
Returns:
Tuple of (frontmatter_dict, remaining_content)
"""
frontmatter = {}
remaining = content
# Check for YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
try:
import yaml
frontmatter = yaml.safe_load(parts[1]) or {}
remaining = parts[2].strip()
except Exception:
# If YAML parsing fails, use content as-is
pass
return frontmatter, remaining
def _load_doc_file(self, file_path: Path, scope: str = "workspace") -> Optional[Doc]:
"""Load a single doc file."""
try:
content = file_path.read_text(encoding="utf-8")
frontmatter, doc_content = self._parse_frontmatter(content)
name = file_path.stem
# Determine priority based on scope
base_priority = 100 if scope == "workspace" else 0
doc = Doc(
name=name,
content=doc_content,
description=frontmatter.get("description", ""),
priority=frontmatter.get("priority", base_priority),
tags=frontmatter.get("tags", []),
file_path=str(file_path)
)
return doc
except Exception as e:
self._log(f"Failed to load doc {file_path}: {e}", logging.WARNING)
return None
def _load_docs_from_dir(self, docs_dir: Path, scope: str = "workspace"):
"""Load all docs from a directory."""
if not docs_dir.exists():
return
for ext in self.SUPPORTED_EXTENSIONS:
for file_path in docs_dir.glob(f"*{ext}"):
if file_path.is_file():
doc = self._load_doc_file(file_path, scope)
if doc:
key = f"{scope}:{doc.name}"
self._docs[key] = doc
self._log(f"Loaded doc: {key}")
def _load_all_docs(self):
"""Load all docs from global and workspace directories."""
self._docs.clear()
# 1. Load global docs (lowest priority)
if self.global_docs_path.exists():
self._load_docs_from_dir(self.global_docs_path, "global")
for key, doc in self._docs.items():
if key.startswith("global:"):
doc.priority = doc.priority - 1000 # Lower priority for global
# 2. Load workspace docs
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
if workspace_docs_dir.exists():
self._load_docs_from_dir(workspace_docs_dir, "workspace")
self._log(f"Loaded {len(self._docs)} docs total")
def reload(self):
"""Reload all docs from disk."""
self._load_all_docs()
def get_all_docs(self) -> List[Doc]:
"""Get all loaded docs, sorted by priority."""
docs = list(self._docs.values())
docs.sort(key=lambda d: d.priority, reverse=True)
return docs
def get_doc(self, name: str) -> Optional[Doc]:
"""Get a specific doc by name."""
# Try workspace first, then global
for scope in ["workspace", "global"]:
key = f"{scope}:{name}"
if key in self._docs:
return self._docs[key]
return None
def get_docs_by_tag(self, tag: str) -> List[Doc]:
"""Get all docs with a specific tag."""
docs = [d for d in self._docs.values() if d.matches_tag(tag)]
docs.sort(key=lambda d: d.priority, reverse=True)
return docs
def get_docs_for_context(
self,
include_docs: Optional[List[str]] = None,
include_tags: Optional[List[str]] = None,
max_chars: int = 50000
) -> List[Doc]:
"""
Get docs for inclusion in agent context.
Args:
include_docs: Specific doc names to include (via @doc mentions)
include_tags: Tags to filter by
max_chars: Maximum total characters
Returns:
List of docs sorted by priority
"""
active = []
# Include specific docs by name
if include_docs:
for name in include_docs:
doc = self.get_doc(name)
if doc and doc not in active:
active.append(doc)
# Include docs by tag
if include_tags:
for tag in include_tags:
for doc in self.get_docs_by_tag(tag):
if doc not in active:
active.append(doc)
# If no specific docs requested, include all high-priority docs
if not include_docs and not include_tags:
for doc in self.get_all_docs():
if doc.priority >= 100: # Only high-priority docs by default
active.append(doc)
# Sort by priority
active.sort(key=lambda d: d.priority, reverse=True)
return active
def format_docs_for_prompt(
self,
include_docs: Optional[List[str]] = None,
include_tags: Optional[List[str]] = None,
max_chars: int = 50000
) -> str:
"""
Format docs as a string for inclusion in prompts.
Args:
include_docs: Specific doc names to include
include_tags: Tags to filter by
max_chars: Maximum total characters
Returns:
Formatted docs string
"""
active_docs = self.get_docs_for_context(include_docs, include_tags, max_chars)
if not active_docs:
return ""
parts = ["# Project Documentation\n"]
total_chars = len(parts[0])
for doc in active_docs:
if total_chars >= max_chars:
break
doc_text = doc.content.strip()
if not doc_text:
continue
# Add doc with header
if doc.description:
header = f"## {doc.name}: {doc.description}"
else:
header = f"## {doc.name}"
section = f"{header}\n{doc_text}\n"
if total_chars + len(section) <= max_chars:
parts.append(section)
total_chars += len(section)
else:
# Truncate last doc
remaining = max_chars - total_chars
if remaining > 100:
parts.append(section[:remaining] + "\n... (truncated)")
break
return "\n".join(parts)
def create_doc(
self,
name: str,
content: str,
description: str = "",
priority: int = 0,
tags: Optional[List[str]] = None,
scope: Literal["global", "workspace"] = "workspace"
) -> Doc:
"""
Create a new doc file.
Args:
name: Doc name (used as filename)
content: Doc content (markdown)
description: Short description
priority: Priority (higher = applied first)
tags: Tags for categorization
scope: Where to save (global or workspace)
Returns:
Created Doc object
"""
# Determine save path
if scope == "global":
docs_dir = self.global_docs_path
else:
docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
docs_dir.mkdir(parents=True, exist_ok=True)
file_path = docs_dir / f"{name}.md"
# Build frontmatter
frontmatter_lines = ["---"]
if description:
frontmatter_lines.append(f'description: "{description}"')
if priority != 0:
frontmatter_lines.append(f"priority: {priority}")
if tags:
tags_str = ", ".join(f'"{t}"' for t in tags)
frontmatter_lines.append(f"tags: [{tags_str}]")
frontmatter_lines.append("---")
frontmatter_lines.append("")
# Write file
full_content = "\n".join(frontmatter_lines) + content
file_path.write_text(full_content, encoding="utf-8")
# Create and register doc
doc = Doc(
name=name,
content=content,
description=description,
priority=priority,
tags=tags or [],
file_path=str(file_path)
)
self._docs[f"{scope}:{name}"] = doc
self._log(f"Created doc '{name}' at {file_path}")
return doc
def delete_doc(self, name: str, scope: Optional[str] = None) -> bool:
"""
Delete a doc file.
Args:
name: Doc name to delete
scope: Scope to delete from (None = try both)
Returns:
True if deleted, False if not found
"""
scopes = [scope] if scope else ["workspace", "global"]
for s in scopes:
key = f"{s}:{name}"
if key in self._docs:
doc = self._docs[key]
if doc.file_path:
try:
Path(doc.file_path).unlink()
del self._docs[key]
self._log(f"Deleted doc '{name}' from {s}")
return True
except Exception as e:
self._log(f"Failed to delete doc file: {e}", logging.ERROR)
return False
def list_docs(self) -> List[Dict[str, Any]]:
"""
List all docs with metadata.
Returns:
List of doc info dicts
"""
docs = []
for key, doc in self._docs.items():
scope = key.split(":")[0]
docs.append({
"name": doc.name,
"scope": scope,
"description": doc.description,
"priority": doc.priority,
"tags": doc.tags,
"file_path": doc.file_path,
"content_length": len(doc.content)
})
# Sort by priority
docs.sort(key=lambda d: d["priority"], reverse=True)
return docs
@@ -0,0 +1,369 @@
"""
MCP Config Manager for PraisonAI Agents.
Provides JSON-based MCP server configuration support similar to:
- Cursor .cursor/mcp/*.mcpjson
- Windsurf MCP configuration
Features:
- Auto-discovery of MCP configs from .praison/mcp/
- JSON config files for MCP servers
- Environment variable support
- Easy server management
Storage Structure:
.praison/mcp/
├── filesystem.json # Filesystem MCP server
├── brave-search.json # Brave search MCP server
└── postgres.json # PostgreSQL MCP server
Config File Format (JSON):
{
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
"env": {
"SOME_VAR": "value"
},
"enabled": true,
"description": "Filesystem access for the agent"
}
"""
import os
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class MCPConfig:
"""A single MCP server configuration."""
name: str
command: str
args: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
enabled: bool = True
description: str = ""
file_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"command": self.command,
"args": self.args,
"env": self.env,
"enabled": self.enabled,
"description": self.description
}
@classmethod
def from_dict(cls, data: Dict[str, Any], file_path: Optional[str] = None) -> 'MCPConfig':
return cls(
name=data.get("name", ""),
command=data.get("command", ""),
args=data.get("args", []),
env=data.get("env", {}),
enabled=data.get("enabled", True),
description=data.get("description", ""),
file_path=file_path
)
def to_mcp_instance(self):
"""
Convert this config to an MCP instance.
Returns:
MCP instance ready for use with agents
"""
try:
from praisonaiagents.tools.mcp import MCP
# Resolve environment variables
resolved_env = {}
for key, value in self.env.items():
if value.startswith("$"):
# Environment variable reference
env_var = value[1:]
resolved_env[key] = os.environ.get(env_var, value)
else:
resolved_env[key] = value
return MCP(
command=self.command,
args=self.args,
env=resolved_env if resolved_env else None
)
except ImportError:
logger.warning("MCP tools not available")
return None
class MCPConfigManager:
"""
Manages MCP server configurations for AI agents.
Provides:
- Auto-discovery of MCP configs from .praison/mcp/
- Global configs from ~/.praison/mcp/
- Easy server management
"""
MCP_DIR_NAME = ".praison/mcp"
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
def __init__(
self,
workspace_path: Optional[str] = None,
global_mcp_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize MCPConfigManager.
Args:
workspace_path: Path to workspace/project root
global_mcp_path: Path to global MCP configs (default: ~/.praison/mcp)
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praison" / "mcp"
self.verbose = verbose
self._configs: Dict[str, MCPConfig] = {}
self._load_all_configs()
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _load_config_file(self, file_path: Path, scope: str = "workspace") -> Optional[MCPConfig]:
"""Load a single MCP config file."""
try:
content = file_path.read_text(encoding="utf-8")
data = json.loads(content)
# Use filename as name if not specified
if not data.get("name"):
data["name"] = file_path.stem
config = MCPConfig.from_dict(data, str(file_path))
return config
except json.JSONDecodeError as e:
self._log(f"Invalid JSON in {file_path}: {e}", logging.WARNING)
return None
except Exception as e:
self._log(f"Failed to load MCP config {file_path}: {e}", logging.WARNING)
return None
def _load_configs_from_dir(self, mcp_dir: Path, scope: str = "workspace"):
"""Load all MCP configs from a directory."""
if not mcp_dir.exists():
return
for ext in self.SUPPORTED_EXTENSIONS:
for file_path in mcp_dir.glob(f"*{ext}"):
if file_path.is_file():
config = self._load_config_file(file_path, scope)
if config:
key = f"{scope}:{config.name}"
self._configs[key] = config
self._log(f"Loaded MCP config: {key}")
def _load_all_configs(self):
"""Load all MCP configs from global and workspace directories."""
self._configs.clear()
# 1. Load global configs
if self.global_mcp_path.exists():
self._load_configs_from_dir(self.global_mcp_path, "global")
# 2. Load workspace configs (override global)
workspace_mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
if workspace_mcp_dir.exists():
self._load_configs_from_dir(workspace_mcp_dir, "workspace")
self._log(f"Loaded {len(self._configs)} MCP configs total")
def reload(self):
"""Reload all configs from disk."""
self._load_all_configs()
def get_all_configs(self) -> List[MCPConfig]:
"""Get all loaded MCP configs."""
return list(self._configs.values())
def get_enabled_configs(self) -> List[MCPConfig]:
"""Get all enabled MCP configs."""
return [c for c in self._configs.values() if c.enabled]
def get_config(self, name: str) -> Optional[MCPConfig]:
"""Get a specific MCP config by name."""
# Try workspace first, then global
for scope in ["workspace", "global"]:
key = f"{scope}:{name}"
if key in self._configs:
return self._configs[key]
return None
def get_mcp_tools(self, names: Optional[List[str]] = None) -> List:
"""
Get MCP instances for use with agents.
Args:
names: Specific config names to load (None = all enabled)
Returns:
List of MCP instances
"""
tools = []
if names:
configs = [self.get_config(name) for name in names]
configs = [c for c in configs if c and c.enabled]
else:
configs = self.get_enabled_configs()
for config in configs:
mcp = config.to_mcp_instance()
if mcp:
tools.append(mcp)
return tools
def create_config(
self,
name: str,
command: str,
args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
enabled: bool = True,
description: str = "",
scope: str = "workspace"
) -> MCPConfig:
"""
Create a new MCP config file.
Args:
name: Config name (used as filename)
command: Command to run (e.g., "npx")
args: Command arguments
env: Environment variables
enabled: Whether the config is enabled
description: Description of the MCP server
scope: Where to save (global or workspace)
Returns:
Created MCPConfig object
"""
# Determine save path
if scope == "global":
mcp_dir = self.global_mcp_path
else:
mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
mcp_dir.mkdir(parents=True, exist_ok=True)
file_path = mcp_dir / f"{name}.json"
# Build config
config = MCPConfig(
name=name,
command=command,
args=args or [],
env=env or {},
enabled=enabled,
description=description,
file_path=str(file_path)
)
# Write file
file_path.write_text(
json.dumps(config.to_dict(), indent=2),
encoding="utf-8"
)
self._configs[f"{scope}:{name}"] = config
self._log(f"Created MCP config '{name}' at {file_path}")
return config
def delete_config(self, name: str, scope: Optional[str] = None) -> bool:
"""
Delete an MCP config file.
Args:
name: Config name to delete
scope: Scope to delete from (None = try both)
Returns:
True if deleted, False if not found
"""
scopes = [scope] if scope else ["workspace", "global"]
for s in scopes:
key = f"{s}:{name}"
if key in self._configs:
config = self._configs[key]
if config.file_path:
try:
Path(config.file_path).unlink()
del self._configs[key]
self._log(f"Deleted MCP config '{name}' from {s}")
return True
except Exception as e:
self._log(f"Failed to delete config file: {e}", logging.ERROR)
return False
def enable_config(self, name: str) -> bool:
"""Enable an MCP config."""
config = self.get_config(name)
if config:
config.enabled = True
self._save_config(config)
return True
return False
def disable_config(self, name: str) -> bool:
"""Disable an MCP config."""
config = self.get_config(name)
if config:
config.enabled = False
self._save_config(config)
return True
return False
def _save_config(self, config: MCPConfig):
"""Save a config back to its file."""
if config.file_path:
Path(config.file_path).write_text(
json.dumps(config.to_dict(), indent=2),
encoding="utf-8"
)
def list_configs(self) -> List[Dict[str, Any]]:
"""
List all MCP configs with metadata.
Returns:
List of config info dicts
"""
configs = []
for key, config in self._configs.items():
scope = key.split(":")[0]
configs.append({
"name": config.name,
"scope": scope,
"command": config.command,
"args": config.args,
"enabled": config.enabled,
"description": config.description,
"file_path": config.file_path
})
return configs
File diff suppressed because it is too large. Load diff
@@ -826,7 +826,34 @@ Provide a JSON with the structure:
logging.info("Hierarchical task execution finished")
def workflow(self):
"""Synchronous version of workflow method"""
"""Synchronous version of workflow method.
DEPRECATED: This method is deprecated. Use the Workflow class instead:
```python
from praisonaiagents import Workflow, StepResult
from praisonaiagents.workflows import route, parallel, loop, repeat
workflow = Workflow(steps=[step1, step2])
result = workflow.start("input")
```
The Workflow class provides a simpler API with more features:
- route() for decision-based branching
- parallel() for concurrent execution
- loop() for iteration over lists/CSV
- repeat() for evaluator-optimizer patterns
- Callbacks (on_step_start, on_step_complete, etc.)
- Guardrails with validation feedback
- Status tracking
"""
import warnings
warnings.warn(
"process='workflow' is deprecated. Use the Workflow class instead: "
"from praisonaiagents import Workflow; workflow = Workflow(steps=[...]); workflow.start()",
DeprecationWarning,
stacklevel=2
)
current_iter = 0 # Track how many times we've looped
# Build workflow relationships first
for task in self.tasks.values():
@@ -44,7 +44,9 @@ class Task:
retain_full_context: bool = False, # By default, only use previous task output, not all previous tasks
guardrail: Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]] = None,
max_retries: int = 3,
retry_count: int = 0
retry_count: int = 0,
agent_config: Optional[Dict[str, Any]] = None,
variables: Optional[Dict[str, Any]] = None
):
# Add check if memory config is provided
if memory is not None or (config and config.get('memory_config')):
@@ -89,6 +91,8 @@ class Task:
self.retry_count = retry_count
self._guardrail_fn = None
self.validation_feedback = None # Store validation failure feedback for retry attempts
self.agent_config = agent_config # Per-task agent configuration {role, goal, backstory, llm}
self.variables = variables if variables else {} # Variables for substitution in description
# Set logger level based on config verbose level
verbose = self.config.get("verbose", 0)
@@ -0,0 +1,319 @@
"""
Mentions Parser for PraisonAI Agents.
Provides @mention syntax support similar to Cursor IDE:
- @file:path/to/file.py - Include file content
- @web:query - Search the web
- @doc:name - Include doc from .praison/docs/
- @rule:name - Include specific rule
Usage:
from praisonaiagents.tools.mentions import MentionsParser
parser = MentionsParser()
context, cleaned_prompt = parser.process("@file:main.py explain this code")
"""
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
class MentionsParser:
"""
Parse and process @mentions in prompts.
Supports:
- @file:path - Include file content
- @web:query - Web search (requires web search tool)
- @doc:name - Include doc from .praison/docs/
- @rule:name - Include specific rule
- @url:https://... - Fetch URL content
"""
# Patterns for different mention types
PATTERNS = {
"file": re.compile(r'@file:([^\s]+)'),
"web": re.compile(r'@web:([^\s]+(?:\s+[^\s@]+)*)'),
"doc": re.compile(r'@doc:([^\s]+)'),
"rule": re.compile(r'@rule:([^\s]+)'),
"url": re.compile(r'@url:(https?://[^\s]+)'),
}
def __init__(
self,
workspace_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize MentionsParser.
Args:
workspace_path: Path to workspace/project root
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.verbose = verbose
# Lazy-loaded managers
self._docs_manager = None
self._rules_manager = None
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _get_docs_manager(self):
"""Lazy load DocsManager."""
if self._docs_manager is None:
try:
from praisonaiagents.memory.docs_manager import DocsManager
self._docs_manager = DocsManager(workspace_path=str(self.workspace_path))
except ImportError:
self._log("DocsManager not available", logging.WARNING)
return self._docs_manager
def _get_rules_manager(self):
"""Lazy load RulesManager."""
if self._rules_manager is None:
try:
from praisonaiagents.memory.rules_manager import RulesManager
self._rules_manager = RulesManager(workspace_path=str(self.workspace_path))
except ImportError:
self._log("RulesManager not available", logging.WARNING)
return self._rules_manager
def process(self, prompt: str) -> Tuple[str, str]:
"""
Process a prompt and extract @mentions.
Args:
prompt: The input prompt with @mentions
Returns:
Tuple of (context_string, cleaned_prompt)
"""
context_parts = []
cleaned_prompt = prompt
# Process each mention type
for mention_type, pattern in self.PATTERNS.items():
matches = pattern.findall(prompt)
for match in matches:
context = self._process_mention(mention_type, match)
if context:
context_parts.append(context)
# Remove the mention from the prompt
cleaned_prompt = pattern.sub('', cleaned_prompt, count=1)
# Clean up extra whitespace
cleaned_prompt = ' '.join(cleaned_prompt.split())
# Build context string
context_string = ""
if context_parts:
context_string = "\n\n".join(context_parts) + "\n\n"
return context_string, cleaned_prompt
def _process_mention(self, mention_type: str, value: str) -> Optional[str]:
"""
Process a single mention.
Args:
mention_type: Type of mention (file, web, doc, rule, url)
value: The mention value
Returns:
Context string or None
"""
if mention_type == "file":
return self._process_file_mention(value)
elif mention_type == "web":
return self._process_web_mention(value)
elif mention_type == "doc":
return self._process_doc_mention(value)
elif mention_type == "rule":
return self._process_rule_mention(value)
elif mention_type == "url":
return self._process_url_mention(value)
return None
def _process_file_mention(self, file_path: str) -> Optional[str]:
"""Process @file:path mention."""
try:
# Resolve path relative to workspace
full_path = self.workspace_path / file_path
if not full_path.exists():
# Try as absolute path
full_path = Path(file_path)
if not full_path.exists():
self._log(f"File not found: {file_path}", logging.WARNING)
return f"# File: {file_path}\n[File not found]"
content = full_path.read_text(encoding="utf-8")
# Limit content size
if len(content) > 50000:
content = content[:50000] + "\n... (truncated)"
# Detect language for code fence
ext = full_path.suffix.lower()
lang_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".jsx": "jsx",
".tsx": "tsx",
".java": "java",
".cpp": "cpp",
".c": "c",
".go": "go",
".rs": "rust",
".rb": "ruby",
".php": "php",
".swift": "swift",
".kt": "kotlin",
".md": "markdown",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".xml": "xml",
".html": "html",
".css": "css",
".sql": "sql",
".sh": "bash",
}
lang = lang_map.get(ext, "")
return f"# File: {file_path}\n```{lang}\n{content}\n```"
except Exception as e:
self._log(f"Error reading file {file_path}: {e}", logging.ERROR)
return f"# File: {file_path}\n[Error reading file: {e}]"
def _process_web_mention(self, query: str) -> Optional[str]:
"""Process @web:query mention."""
try:
# Try to use web search
from praisonaiagents.tools import duckduckgo_search
results = duckduckgo_search(query, max_results=3)
if results:
return f"# Web Search: {query}\n{results}"
else:
return f"# Web Search: {query}\n[No results found]"
except ImportError:
self._log("Web search not available", logging.WARNING)
return f"# Web Search: {query}\n[Web search not available. Install with: pip install duckduckgo-search]"
except Exception as e:
self._log(f"Error in web search: {e}", logging.ERROR)
return f"# Web Search: {query}\n[Error: {e}]"
def _process_doc_mention(self, doc_name: str) -> Optional[str]:
"""Process @doc:name mention."""
docs = self._get_docs_manager()
if not docs:
return f"# Doc: {doc_name}\n[DocsManager not available]"
doc = docs.get_doc(doc_name)
if doc:
return f"# Doc: {doc.name}\n{doc.content}"
else:
return f"# Doc: {doc_name}\n[Doc not found]"
def _process_rule_mention(self, rule_name: str) -> Optional[str]:
"""Process @rule:name mention."""
rules = self._get_rules_manager()
if not rules:
return f"# Rule: {rule_name}\n[RulesManager not available]"
rule = rules.get_rule(rule_name)
if rule:
return f"# Rule: {rule.name}\n{rule.content}"
else:
return f"# Rule: {rule_name}\n[Rule not found]"
def _process_url_mention(self, url: str) -> Optional[str]:
"""Process @url:https://... mention."""
try:
import urllib.request
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
)
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read().decode('utf-8', errors='ignore')
# Limit content size
if len(content) > 50000:
content = content[:50000] + "\n... (truncated)"
# Try to extract text from HTML
if '<html' in content.lower():
# Simple HTML text extraction
import re
# Remove scripts and styles
content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<style[^>]*>.*?</style>', '', content, flags=re.DOTALL | re.IGNORECASE)
# Remove tags
content = re.sub(r'<[^>]+>', ' ', content)
# Clean whitespace
content = ' '.join(content.split())
return f"# URL: {url}\n{content[:10000]}"
except Exception as e:
self._log(f"Error fetching URL {url}: {e}", logging.ERROR)
return f"# URL: {url}\n[Error fetching URL: {e}]"
def extract_mentions(self, prompt: str) -> Dict[str, List[str]]:
"""
Extract all mentions from a prompt without processing them.
Args:
prompt: The input prompt
Returns:
Dict mapping mention types to lists of values
"""
mentions = {}
for mention_type, pattern in self.PATTERNS.items():
matches = pattern.findall(prompt)
if matches:
mentions[mention_type] = matches
return mentions
def has_mentions(self, prompt: str) -> bool:
"""Check if a prompt contains any @mentions."""
for pattern in self.PATTERNS.values():
if pattern.search(prompt):
return True
return False
def process_mentions(prompt: str, workspace_path: Optional[str] = None) -> Tuple[str, str]:
"""
Convenience function to process mentions in a prompt.
Args:
prompt: The input prompt with @mentions
workspace_path: Optional workspace path
Returns:
Tuple of (context_string, cleaned_prompt)
"""
parser = MentionsParser(workspace_path=workspace_path)
return parser.process(prompt)
# Export
__all__ = ["MentionsParser", "process_mentions"]
@@ -0,0 +1,265 @@
"""
Rules Tools for PraisonAI Agents.
Provides tools for agents to create, read, and manage rules dynamically.
This enables Agent-Requested rules similar to Cursor's agent-requested rules feature.
Usage:
from praisonaiagents.tools.rules_tools import (
create_rule_tool,
list_rules_tool,
get_rule_tool,
delete_rule_tool
)
agent = Agent(
name="Assistant",
tools=[create_rule_tool, list_rules_tool, get_rule_tool]
)
"""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# Singleton RulesManager instance
_rules_manager = None
def _get_rules_manager():
"""Get or create the RulesManager singleton."""
global _rules_manager
if _rules_manager is None:
from praisonaiagents.memory.rules_manager import RulesManager
_rules_manager = RulesManager()
return _rules_manager
def create_rule_tool(
name: str,
content: str,
description: str = "",
globs: Optional[str] = None,
activation: str = "always",
priority: int = 0,
scope: str = "workspace"
) -> str:
"""
Create a new rule for the AI agent to follow.
This tool allows the agent to create persistent rules that will be
applied to future conversations and tasks.
Args:
name: Rule name (used as filename, no spaces or special chars)
content: The rule content in markdown format
description: Short description of what the rule does
globs: Comma-separated glob patterns for file matching (e.g., "**/*.py,**/*.js")
activation: When to apply the rule:
- "always": Always apply this rule
- "glob": Apply when working with files matching globs
- "manual": Only apply when explicitly mentioned
- "ai_decision": Let AI decide when to apply
priority: Priority (higher = applied first, default 0)
scope: Where to save ("workspace" or "global")
Returns:
Success message with rule details
Example:
create_rule_tool(
name="python-style",
content="- Use type hints for all functions\\n- Follow PEP 8",
description="Python coding standards",
globs="**/*.py",
activation="glob"
)
"""
try:
manager = _get_rules_manager()
# Parse globs
glob_list = None
if globs:
glob_list = [g.strip() for g in globs.split(",") if g.strip()]
# Validate activation
valid_activations = ["always", "glob", "manual", "ai_decision"]
if activation not in valid_activations:
return f"Error: activation must be one of {valid_activations}"
# Validate scope
if scope not in ["workspace", "global"]:
return "Error: scope must be 'workspace' or 'global'"
# Create the rule
rule = manager.create_rule(
name=name,
content=content,
description=description,
globs=glob_list,
activation=activation,
priority=priority,
scope=scope
)
return f"""Successfully created rule '{name}'
Location: {rule.file_path}
Activation: {activation}
Priority: {priority}
Description: {description or 'None'}
Globs: {glob_list or 'None'}
The rule will be applied according to its activation mode."""
except Exception as e:
logger.error(f"Error creating rule: {e}")
return f"Error creating rule: {str(e)}"
def list_rules_tool() -> str:
"""
List all available rules.
Returns a formatted list of all rules with their metadata.
Returns:
Formatted string with rule information
"""
try:
manager = _get_rules_manager()
rules = manager.get_all_rules()
if not rules:
return "No rules found. Use create_rule_tool to create new rules."
lines = ["# Available Rules\n"]
for rule in rules:
scope = "global" if "global:" in (rule.file_path or "") else "workspace"
lines.append(f"## {rule.name}")
lines.append(f"- **Activation**: {rule.activation}")
lines.append(f"- **Priority**: {rule.priority}")
if rule.description:
lines.append(f"- **Description**: {rule.description}")
if rule.globs:
lines.append(f"- **Globs**: {', '.join(rule.globs)}")
lines.append(f"- **Scope**: {scope}")
lines.append("")
return "\n".join(lines)
except Exception as e:
logger.error(f"Error listing rules: {e}")
return f"Error listing rules: {str(e)}"
def get_rule_tool(name: str) -> str:
"""
Get the content of a specific rule.
Args:
name: The name of the rule to retrieve
Returns:
The rule content and metadata
"""
try:
manager = _get_rules_manager()
rule = manager.get_rule(name)
if not rule:
return f"Rule '{name}' not found. Use list_rules_tool to see available rules."
return f"""# Rule: {rule.name}
**Description**: {rule.description or 'None'}
**Activation**: {rule.activation}
**Priority**: {rule.priority}
**Globs**: {', '.join(rule.globs) if rule.globs else 'None'}
**File**: {rule.file_path}
## Content:
{rule.content}
"""
except Exception as e:
logger.error(f"Error getting rule: {e}")
return f"Error getting rule: {str(e)}"
def delete_rule_tool(name: str, scope: Optional[str] = None) -> str:
"""
Delete a rule.
Args:
name: The name of the rule to delete
scope: Optional scope to delete from ("workspace" or "global")
Returns:
Success or error message
"""
try:
manager = _get_rules_manager()
if scope and scope not in ["workspace", "global"]:
return "Error: scope must be 'workspace' or 'global'"
success = manager.delete_rule(name, scope)
if success:
return f"Successfully deleted rule '{name}'"
else:
return f"Rule '{name}' not found or could not be deleted"
except Exception as e:
logger.error(f"Error deleting rule: {e}")
return f"Error deleting rule: {str(e)}"
def get_active_rules_tool(file_path: Optional[str] = None) -> str:
"""
Get all rules that are currently active for a given file or context.
Args:
file_path: Optional file path to check which rules apply
Returns:
Formatted string with active rules
"""
try:
manager = _get_rules_manager()
if file_path:
rules = manager.get_rules_for_file(file_path)
else:
rules = manager.get_active_rules()
if not rules:
return "No active rules for this context."
lines = ["# Active Rules\n"]
for rule in rules:
lines.append(f"## {rule.name}")
if rule.description:
lines.append(f"*{rule.description}*\n")
lines.append(rule.content)
lines.append("")
return "\n".join(lines)
except Exception as e:
logger.error(f"Error getting active rules: {e}")
return f"Error getting active rules: {str(e)}"
# Export all tools
__all__ = [
"create_rule_tool",
"list_rules_tool",
"get_rule_tool",
"delete_rule_tool",
"get_active_rules_tool"
]
@@ -0,0 +1,55 @@
"""
Workflows module for PraisonAI Agents.
Provides workflow/pipeline patterns for orchestrating agents and functions.
"""
from .workflows import (
# Core classes
Workflow,
Pipeline, # Alias for Workflow
WorkflowStep,
WorkflowContext,
StepResult,
WorkflowManager,
# Pattern helpers
Route,
Parallel,
Loop,
Repeat,
# Convenience functions
route,
parallel,
loop,
repeat,
# Backward compatibility aliases
StepInput,
StepOutput,
)
__all__ = [
# Core
"Workflow",
"Pipeline",
"WorkflowStep",
"WorkflowContext",
"StepResult",
"WorkflowManager",
# Patterns
"Route",
"Parallel",
"Loop",
"Repeat",
"route",
"parallel",
"loop",
"repeat",
# Aliases
"StepInput",
"StepOutput",
]
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.189"
version = "0.1.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
Whitespace-only changes.
@@ -0,0 +1,252 @@
"""
Tests for FastContext high-level API.
"""
import pytest
import tempfile
import time
from pathlib import Path
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
src_dir = Path(tmpdir) / "src"
src_dir.mkdir()
(src_dir / "main.py").write_text("""
def main():
print("Hello, World!")
authenticate_user()
def authenticate_user():
# Authentication logic here
pass
class UserService:
def login(self, username, password):
return True
""")
(src_dir / "utils.py").write_text("""
import os
def get_config():
return {"debug": True}
def validate_input(data):
if not data:
raise ValueError("Empty data")
return True
""")
auth_dir = src_dir / "auth"
auth_dir.mkdir()
(auth_dir / "handler.py").write_text("""
from typing import Optional
class AuthHandler:
def __init__(self):
self.token = None
def authenticate(self, credentials: dict) -> bool:
return True
def logout(self):
self.token = None
""")
yield tmpdir
class TestFastContextInit:
"""Tests for FastContext initialization."""
def test_default_initialization(self, temp_workspace):
"""Test default initialization."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
assert fc.workspace_path == temp_workspace
assert fc.max_turns == 4
assert fc.max_parallel == 8
assert fc.model == "gpt-4o-mini"
assert fc.cache_enabled is True
def test_custom_initialization(self, temp_workspace):
"""Test custom initialization."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(
workspace_path=temp_workspace,
max_turns=3,
max_parallel=4,
model="claude-3-haiku-20240307",
cache_enabled=False
)
assert fc.max_turns == 3
assert fc.max_parallel == 4
assert fc.model == "claude-3-haiku-20240307"
assert fc.cache_enabled is False
def test_context_manager(self, temp_workspace):
"""Test context manager usage."""
from praisonaiagents.context.fast import FastContext
with FastContext(workspace_path=temp_workspace) as fc:
result = fc.search("def")
assert result.total_files > 0
class TestFastContextSearch:
"""Tests for FastContext search functionality."""
def test_search_basic(self, temp_workspace):
"""Test basic search."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
result = fc.search("authenticate")
assert result.total_files > 0
file_paths = [f.path for f in result.files]
assert any("main.py" in p for p in file_paths)
def test_search_with_patterns(self, temp_workspace):
"""Test search with include patterns."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
result = fc.search("def", include_patterns=["**/auth/*.py"])
for f in result.files:
assert "auth" in f.path
def test_search_no_results(self, temp_workspace):
"""Test search with no matches."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
result = fc.search("nonexistent_xyz_123")
assert result.total_files == 0
class TestFastContextCache:
"""Tests for FastContext caching."""
def test_cache_hit(self, temp_workspace):
"""Test cache hit on repeated query."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace, cache_enabled=True)
# First search
result1 = fc.search("authenticate")
assert result1.from_cache is False
# Second search (should hit cache)
result2 = fc.search("authenticate")
assert result2.from_cache is True
def test_cache_disabled(self, temp_workspace):
"""Test cache disabled."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace, cache_enabled=False)
result1 = fc.search("authenticate")
result2 = fc.search("authenticate")
assert result1.from_cache is False
assert result2.from_cache is False
def test_clear_cache(self, temp_workspace):
"""Test clearing cache."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace, cache_enabled=True)
fc.search("authenticate")
fc.clear_cache()
result = fc.search("authenticate")
assert result.from_cache is False
class TestFastContextFiles:
"""Tests for FastContext file operations."""
def test_search_files(self, temp_workspace):
"""Test searching for files."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
result = fc.search_files("**/*.py")
assert result.total_files >= 3
def test_read_context(self, temp_workspace):
"""Test reading file context."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
content = fc.read_context("src/main.py")
assert content is not None
assert "def main():" in content
def test_read_context_with_lines(self, temp_workspace):
"""Test reading specific lines."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
content = fc.read_context("src/main.py", start_line=1, end_line=5, context_lines=0)
assert content is not None
lines = content.split("\n")
assert len(lines) <= 5
class TestFastContextAgentIntegration:
"""Tests for FastContext agent integration."""
def test_get_context_for_agent(self, temp_workspace):
"""Test getting formatted context for agent."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
context = fc.get_context_for_agent("authenticate")
assert "Relevant Code Context" in context
assert "authenticate" in context.lower()
def test_get_context_for_agent_no_results(self, temp_workspace):
"""Test context for agent with no results."""
from praisonaiagents.context.fast import FastContext
fc = FastContext(workspace_path=temp_workspace)
context = fc.get_context_for_agent("nonexistent_xyz_123")
assert "No relevant code found" in context
class TestFastSearchFunction:
"""Tests for fast_search convenience function."""
def test_fast_search(self, temp_workspace):
"""Test fast_search function."""
from praisonaiagents.context.fast.fast_context import fast_search
result = fast_search("authenticate", workspace_path=temp_workspace)
assert result.total_files > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,205 @@
"""
Tests for FastContextAgent class.
"""
import pytest
import tempfile
from pathlib import Path
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
src_dir = Path(tmpdir) / "src"
src_dir.mkdir()
(src_dir / "main.py").write_text("""
def main():
print("Hello, World!")
authenticate_user()
def authenticate_user():
# Authentication logic here
pass
class UserService:
def login(self, username, password):
return True
""")
(src_dir / "utils.py").write_text("""
import os
def get_config():
return {"debug": True}
def validate_input(data):
if not data:
raise ValueError("Empty data")
return True
""")
auth_dir = src_dir / "auth"
auth_dir.mkdir()
(auth_dir / "handler.py").write_text("""
from typing import Optional
class AuthHandler:
def __init__(self):
self.token = None
def authenticate(self, credentials: dict) -> bool:
return True
def logout(self):
self.token = None
""")
yield tmpdir
class TestFastContextAgentInit:
"""Tests for FastContextAgent initialization."""
def test_default_initialization(self, temp_workspace):
"""Test default initialization."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
assert agent.workspace_path == temp_workspace
assert agent.max_turns == 4
assert agent.max_parallel == 8
assert agent.model == "gpt-4o-mini"
def test_custom_initialization(self, temp_workspace):
"""Test custom initialization."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(
workspace_path=temp_workspace,
max_turns=3,
max_parallel=4,
model="claude-3-haiku-20240307"
)
assert agent.max_turns == 3
assert agent.max_parallel == 4
assert agent.model == "claude-3-haiku-20240307"
class TestFastContextAgentSearch:
"""Tests for FastContextAgent search functionality."""
def test_search_returns_result(self, temp_workspace):
"""Test that search returns FastContextResult."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
from praisonaiagents.context.fast.result import FastContextResult
agent = FastContextAgent(workspace_path=temp_workspace)
# Use simple search without LLM
result = agent.search_simple("authenticate")
assert isinstance(result, FastContextResult)
assert result.total_files > 0
def test_search_finds_relevant_files(self, temp_workspace):
"""Test that search finds relevant files."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
result = agent.search_simple("authenticate")
# Should find files containing "authenticate"
file_paths = [f.path for f in result.files]
assert any("main.py" in p for p in file_paths)
assert any("handler.py" in p for p in file_paths)
def test_search_with_file_pattern(self, temp_workspace):
"""Test search with file pattern filter."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
result = agent.search_simple("def", include_patterns=["**/auth/*.py"])
# Should only find in auth directory
for f in result.files:
assert "auth" in f.path
def test_search_records_timing(self, temp_workspace):
"""Test that search records timing information."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
result = agent.search_simple("def")
# search_time_ms >= 0 is valid (can be 0 for very fast searches)
assert result.search_time_ms >= 0
assert result.turns_used == 1
assert result.total_tool_calls == 1
def test_search_no_results(self, temp_workspace):
"""Test search with no matches."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
result = agent.search_simple("nonexistent_pattern_xyz123")
assert result.total_files == 0
class TestFastContextAgentTools:
"""Tests for FastContextAgent tool definitions."""
def test_get_tools(self, temp_workspace):
"""Test getting tool definitions."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
tools = agent.get_tools()
assert len(tools) == 4
tool_names = [t["name"] for t in tools]
assert "grep_search" in tool_names
assert "glob_search" in tool_names
assert "read_file" in tool_names
assert "list_directory" in tool_names
def test_execute_tool(self, temp_workspace):
"""Test executing a tool directly."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
result = agent.execute_tool("glob_search", pattern="**/*.py")
assert len(result) >= 3 # At least 3 .py files
class TestFastContextAgentLLMSearch:
"""Tests for FastContextAgent LLM-powered search."""
def test_search_falls_back_to_simple(self, temp_workspace):
"""Test that search falls back to simple when LLM not available."""
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
agent = FastContextAgent(workspace_path=temp_workspace)
# search() should fall back to search_simple when LLM is not available
result = agent.search("find authentication code")
# Should still return results (from simple search fallback)
assert result is not None
assert result.query == "find authentication code"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,269 @@
"""
Tests for Fast Context result classes.
"""
import pytest
import time
class TestLineRange:
"""Tests for LineRange dataclass."""
def test_line_range_creation(self):
"""Test basic LineRange creation."""
from praisonaiagents.context.fast.result import LineRange
lr = LineRange(start=10, end=20)
assert lr.start == 10
assert lr.end == 20
assert lr.content is None
assert lr.relevance_score == 1.0
def test_line_range_with_content(self):
"""Test LineRange with content."""
from praisonaiagents.context.fast.result import LineRange
lr = LineRange(start=1, end=3, content="line1\nline2\nline3")
assert lr.content == "line1\nline2\nline3"
def test_line_range_invalid_start(self):
"""Test LineRange corrects invalid start."""
from praisonaiagents.context.fast.result import LineRange
lr = LineRange(start=0, end=5)
assert lr.start == 1 # Corrected to 1
def test_line_range_invalid_end(self):
"""Test LineRange corrects invalid end."""
from praisonaiagents.context.fast.result import LineRange
lr = LineRange(start=10, end=5)
assert lr.end == 10 # Corrected to start
def test_line_count(self):
"""Test line_count property."""
from praisonaiagents.context.fast.result import LineRange
lr = LineRange(start=10, end=20)
assert lr.line_count == 11
def test_overlaps_true(self):
"""Test overlaps returns True for overlapping ranges."""
from praisonaiagents.context.fast.result import LineRange
lr1 = LineRange(start=10, end=20)
lr2 = LineRange(start=15, end=25)
assert lr1.overlaps(lr2) is True
assert lr2.overlaps(lr1) is True
def test_overlaps_false(self):
"""Test overlaps returns False for non-overlapping ranges."""
from praisonaiagents.context.fast.result import LineRange
lr1 = LineRange(start=10, end=20)
lr2 = LineRange(start=25, end=30)
assert lr1.overlaps(lr2) is False
def test_overlaps_adjacent(self):
"""Test overlaps for adjacent ranges."""
from praisonaiagents.context.fast.result import LineRange
lr1 = LineRange(start=10, end=20)
lr2 = LineRange(start=21, end=30)
assert lr1.overlaps(lr2) is False
def test_merge(self):
"""Test merging overlapping ranges."""
from praisonaiagents.context.fast.result import LineRange
lr1 = LineRange(start=10, end=20, relevance_score=0.8)
lr2 = LineRange(start=15, end=25, relevance_score=0.9)
merged = lr1.merge(lr2)
assert merged.start == 10
assert merged.end == 25
assert merged.relevance_score == 0.9 # Max of both
def test_merge_non_overlapping_raises(self):
"""Test merge raises for non-overlapping ranges."""
from praisonaiagents.context.fast.result import LineRange
lr1 = LineRange(start=10, end=20)
lr2 = LineRange(start=25, end=30)
with pytest.raises(ValueError):
lr1.merge(lr2)
class TestFileMatch:
"""Tests for FileMatch dataclass."""
def test_file_match_creation(self):
"""Test basic FileMatch creation."""
from praisonaiagents.context.fast.result import FileMatch
fm = FileMatch(path="/path/to/file.py")
assert fm.path == "/path/to/file.py"
assert fm.line_ranges == []
assert fm.relevance_score == 1.0
assert fm.match_count == 0
def test_add_line_range(self):
"""Test adding line ranges."""
from praisonaiagents.context.fast.result import FileMatch, LineRange
fm = FileMatch(path="/path/to/file.py")
fm.add_line_range(LineRange(start=10, end=20))
assert len(fm.line_ranges) == 1
assert fm.line_ranges[0].start == 10
def test_add_overlapping_line_ranges(self):
"""Test adding overlapping line ranges merges them."""
from praisonaiagents.context.fast.result import FileMatch, LineRange
fm = FileMatch(path="/path/to/file.py")
fm.add_line_range(LineRange(start=10, end=20))
fm.add_line_range(LineRange(start=15, end=25))
assert len(fm.line_ranges) == 1
assert fm.line_ranges[0].start == 10
assert fm.line_ranges[0].end == 25
def test_add_non_overlapping_line_ranges(self):
"""Test adding non-overlapping line ranges keeps them separate."""
from praisonaiagents.context.fast.result import FileMatch, LineRange
fm = FileMatch(path="/path/to/file.py")
fm.add_line_range(LineRange(start=10, end=20))
fm.add_line_range(LineRange(start=30, end=40))
assert len(fm.line_ranges) == 2
def test_total_lines(self):
"""Test total_lines property."""
from praisonaiagents.context.fast.result import FileMatch, LineRange
fm = FileMatch(path="/path/to/file.py")
fm.add_line_range(LineRange(start=10, end=20)) # 11 lines
fm.add_line_range(LineRange(start=30, end=35)) # 6 lines
assert fm.total_lines == 17
class TestFastContextResult:
"""Tests for FastContextResult dataclass."""
def test_result_creation(self):
"""Test basic result creation."""
from praisonaiagents.context.fast.result import FastContextResult
result = FastContextResult(query="find authentication")
assert result.query == "find authentication"
assert result.files == []
assert result.search_time_ms == 0
def test_timer(self):
"""Test timer functionality."""
from praisonaiagents.context.fast.result import FastContextResult
result = FastContextResult()
result.start_timer()
time.sleep(0.1) # 100ms
result.stop_timer()
assert result.search_time_ms >= 100
assert result.search_time_ms < 200 # Should be close to 100ms
def test_add_file(self):
"""Test adding files."""
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
result = FastContextResult()
result.add_file(FileMatch(path="/path/to/file.py"))
assert result.total_files == 1
def test_add_duplicate_file_merges(self):
"""Test adding duplicate file path merges them."""
from praisonaiagents.context.fast.result import (
FastContextResult, FileMatch, LineRange
)
result = FastContextResult()
fm1 = FileMatch(path="/path/to/file.py", relevance_score=0.8)
fm1.add_line_range(LineRange(start=10, end=20))
result.add_file(fm1)
fm2 = FileMatch(path="/path/to/file.py", relevance_score=0.9)
fm2.add_line_range(LineRange(start=30, end=40))
result.add_file(fm2)
assert result.total_files == 1
assert len(result.files[0].line_ranges) == 2
assert result.files[0].relevance_score == 0.9
def test_sort_by_relevance(self):
"""Test sorting files by relevance."""
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
result = FastContextResult()
result.add_file(FileMatch(path="/low.py", relevance_score=0.3))
result.add_file(FileMatch(path="/high.py", relevance_score=0.9))
result.add_file(FileMatch(path="/mid.py", relevance_score=0.6))
result.sort_by_relevance()
assert result.files[0].path == "/high.py"
assert result.files[1].path == "/mid.py"
assert result.files[2].path == "/low.py"
def test_to_context_string(self):
"""Test converting result to context string."""
from praisonaiagents.context.fast.result import (
FastContextResult, FileMatch, LineRange
)
result = FastContextResult()
fm = FileMatch(path="/path/to/file.py")
fm.add_line_range(LineRange(start=10, end=20))
result.add_file(fm)
context = result.to_context_string()
assert "1 relevant file" in context
assert "/path/to/file.py" in context
assert "Lines 10-20" in context
def test_to_context_string_empty(self):
"""Test context string for empty result."""
from praisonaiagents.context.fast.result import FastContextResult
result = FastContextResult()
context = result.to_context_string()
assert "No relevant files found" in context
def test_to_dict(self):
"""Test converting result to dictionary."""
from praisonaiagents.context.fast.result import (
FastContextResult, FileMatch, LineRange
)
result = FastContextResult(query="test", search_time_ms=100, turns_used=2)
fm = FileMatch(path="/file.py", relevance_score=0.8)
fm.add_line_range(LineRange(start=10, end=20))
result.add_file(fm)
d = result.to_dict()
assert d["query"] == "test"
assert d["search_time_ms"] == 100
assert d["turns_used"] == 2
assert len(d["files"]) == 1
assert d["files"][0]["path"] == "/file.py"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,261 @@
"""
Tests for File Indexer.
"""
import pytest
import tempfile
import os
from pathlib import Path
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
src_dir = Path(tmpdir) / "src"
src_dir.mkdir()
(src_dir / "main.py").write_text("def main(): pass")
(src_dir / "utils.py").write_text("def util(): pass")
(src_dir / "config.json").write_text('{"key": "value"}')
auth_dir = src_dir / "auth"
auth_dir.mkdir()
(auth_dir / "handler.py").write_text("class Handler: pass")
# Create .gitignore
(Path(tmpdir) / ".gitignore").write_text("__pycache__/\n*.pyc\n")
# Create ignored directory
cache_dir = Path(tmpdir) / "__pycache__"
cache_dir.mkdir()
(cache_dir / "cached.pyc").write_text("cached")
yield tmpdir
class TestFileIndexerInit:
"""Tests for FileIndexer initialization."""
def test_default_initialization(self, temp_workspace):
"""Test default initialization."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
assert indexer.workspace_path == temp_workspace
assert indexer.respect_gitignore is True
assert indexer.total_files == 0
def test_custom_extensions(self, temp_workspace):
"""Test custom extensions."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(
workspace_path=temp_workspace,
extensions={".py"}
)
assert indexer.extensions == {".py"}
class TestFileIndexerIndex:
"""Tests for FileIndexer indexing."""
def test_index_files(self, temp_workspace):
"""Test indexing files."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
count = indexer.index()
assert count >= 3 # At least main.py, utils.py, handler.py
assert indexer.total_files >= 3
def test_index_respects_gitignore(self, temp_workspace):
"""Test that indexing respects .gitignore."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace, respect_gitignore=True)
indexer.index()
# Should not include __pycache__ files
for path in indexer.files:
assert "__pycache__" not in path
assert not path.endswith(".pyc")
def test_index_by_extension(self, temp_workspace):
"""Test indexing organizes by extension."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
assert ".py" in indexer.by_extension
assert len(indexer.by_extension[".py"]) >= 3
def test_index_with_extension_filter(self, temp_workspace):
"""Test indexing with extension filter."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace, extensions={".py"})
indexer.index()
# Should only have .py files
for path in indexer.files:
assert path.endswith(".py")
class TestFileIndexerFind:
"""Tests for FileIndexer find methods."""
def test_find_by_pattern(self, temp_workspace):
"""Test finding files by pattern."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
results = indexer.find_by_pattern("**/*.py")
assert len(results) >= 3
for info in results:
assert info.path.endswith(".py")
def test_find_by_extension(self, temp_workspace):
"""Test finding files by extension."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
results = indexer.find_by_extension(".py")
assert len(results) >= 3
def test_find_by_extension_without_dot(self, temp_workspace):
"""Test finding files by extension without dot."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
results = indexer.find_by_extension("py")
assert len(results) >= 3
def test_find_by_name_exact(self, temp_workspace):
"""Test finding files by exact name."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
results = indexer.find_by_name("main.py", exact=True)
assert len(results) == 1
assert results[0].path.endswith("main.py")
def test_find_by_name_partial(self, temp_workspace):
"""Test finding files by partial name."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
results = indexer.find_by_name("handler")
assert len(results) >= 1
assert any("handler" in r.path for r in results)
def test_get_file(self, temp_workspace):
"""Test getting file by path."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
# Find a file first
results = indexer.find_by_name("main.py", exact=True)
assert len(results) > 0
# Get by path
file_info = indexer.get_file(results[0].path)
assert file_info is not None
assert file_info.path == results[0].path
class TestFileIndexerStats:
"""Tests for FileIndexer statistics."""
def test_get_stats(self, temp_workspace):
"""Test getting index statistics."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
stats = indexer.get_stats()
assert stats["total_files"] >= 3
assert stats["total_size_bytes"] > 0
assert ".py" in stats["extensions"]
def test_is_stale(self, temp_workspace):
"""Test stale check."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
# Before indexing, should be stale
assert indexer.is_stale() is True
indexer.index()
# After indexing, should not be stale
assert indexer.is_stale(max_age=60.0) is False
class TestFileIndexerPersistence:
"""Tests for FileIndexer save/load."""
def test_save_and_load(self, temp_workspace):
"""Test saving and loading index."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
# Save index
index_path = os.path.join(temp_workspace, ".fast_context_index.json")
indexer.save_index(index_path)
assert os.path.exists(index_path)
# Load into new indexer
indexer2 = FileIndexer(workspace_path=temp_workspace)
loaded = indexer2.load_index(index_path)
assert loaded is True
assert indexer2.total_files == indexer.total_files
def test_load_wrong_workspace(self, temp_workspace):
"""Test loading index from wrong workspace."""
from praisonaiagents.context.fast.indexer import FileIndexer
indexer = FileIndexer(workspace_path=temp_workspace)
indexer.index()
index_path = os.path.join(temp_workspace, ".fast_context_index.json")
indexer.save_index(index_path)
# Try to load with different workspace
indexer2 = FileIndexer(workspace_path="/different/path")
loaded = indexer2.load_index(index_path)
assert loaded is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,205 @@
"""
Tests for Fast Context parallel executor.
"""
import pytest
import asyncio
import time
import tempfile
from pathlib import Path
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create some test files
for i in range(5):
(Path(tmpdir) / f"file{i}.py").write_text(f"# File {i}\ndef func{i}(): pass")
yield tmpdir
class TestParallelExecutor:
"""Tests for ParallelExecutor class."""
@pytest.mark.asyncio
async def test_execute_single_task(self, temp_workspace):
"""Test executing a single task."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
from praisonaiagents.context.fast.search_tools import glob_search
executor = ParallelExecutor(max_parallel=8)
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
]
results = await executor.execute(tasks)
assert len(results) == 1
assert len(results[0]) == 5 # 5 .py files
@pytest.mark.asyncio
async def test_execute_multiple_tasks_parallel(self, temp_workspace):
"""Test executing multiple tasks in parallel."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8)
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}},
{"tool": "grep_search", "args": {"search_path": temp_workspace, "pattern": "def"}},
{"tool": "list_directory", "args": {"dir_path": temp_workspace}},
]
start = time.perf_counter()
results = await executor.execute(tasks)
elapsed = time.perf_counter() - start
assert len(results) == 3
# All tasks should complete (parallel execution)
assert all(r is not None for r in results)
@pytest.mark.asyncio
async def test_execute_with_timeout(self, temp_workspace):
"""Test execution with timeout."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8, timeout=0.001) # Very short timeout
# This should timeout for slow operations
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "**/*.py"}}
]
results = await executor.execute(tasks)
# Should return results (either success or timeout error)
assert len(results) == 1
@pytest.mark.asyncio
async def test_execute_respects_max_parallel(self, temp_workspace):
"""Test that max_parallel limit is respected."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=2)
# Create more tasks than max_parallel
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
for _ in range(5)
]
results = await executor.execute(tasks)
assert len(results) == 5
# All should complete successfully
assert all(len(r) == 5 for r in results)
@pytest.mark.asyncio
async def test_execute_handles_errors(self, temp_workspace):
"""Test that errors in one task don't affect others."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8)
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}},
{"tool": "read_file", "args": {"filepath": "/nonexistent/file.py"}}, # Will fail
{"tool": "list_directory", "args": {"dir_path": temp_workspace}},
]
results = await executor.execute(tasks)
assert len(results) == 3
# First and third should succeed
assert len(results[0]) == 5
assert results[1]["success"] is False # Error result
assert results[2]["success"] is True
@pytest.mark.asyncio
async def test_execute_unknown_tool(self, temp_workspace):
"""Test handling of unknown tool."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8)
tasks = [
{"tool": "unknown_tool", "args": {}}
]
results = await executor.execute(tasks)
assert len(results) == 1
assert "error" in results[0]
@pytest.mark.asyncio
async def test_execute_empty_tasks(self):
"""Test executing empty task list."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8)
results = await executor.execute([])
assert results == []
def test_execute_sync(self, temp_workspace):
"""Test synchronous execution wrapper."""
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
executor = ParallelExecutor(max_parallel=8)
tasks = [
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
]
results = executor.execute_sync(tasks)
assert len(results) == 1
assert len(results[0]) == 5
class TestToolCallBatch:
"""Tests for ToolCallBatch class."""
def test_batch_creation(self):
"""Test creating a tool call batch."""
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
batch = ToolCallBatch()
batch.add("grep_search", search_path="/path", pattern="test")
batch.add("glob_search", search_path="/path", pattern="*.py")
assert len(batch.tasks) == 2
def test_batch_max_size(self):
"""Test batch respects max size."""
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
batch = ToolCallBatch(max_size=2)
batch.add("grep_search", search_path="/path", pattern="test1")
batch.add("grep_search", search_path="/path", pattern="test2")
# Should not add beyond max_size
added = batch.add("grep_search", search_path="/path", pattern="test3")
assert added is False
assert len(batch.tasks) == 2
def test_batch_is_full(self):
"""Test is_full property."""
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
batch = ToolCallBatch(max_size=2)
assert batch.is_full is False
batch.add("grep_search", search_path="/path", pattern="test1")
assert batch.is_full is False
batch.add("grep_search", search_path="/path", pattern="test2")
assert batch.is_full is True
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,336 @@
"""
Tests for Fast Context search tools.
"""
import pytest
import tempfile
import os
from pathlib import Path
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
src_dir = Path(tmpdir) / "src"
src_dir.mkdir()
# Create Python files
(src_dir / "main.py").write_text("""
def main():
print("Hello, World!")
authenticate_user()
def authenticate_user():
# Authentication logic here
pass
class UserService:
def login(self, username, password):
return True
""")
(src_dir / "utils.py").write_text("""
import os
def get_config():
return {"debug": True}
def validate_input(data):
if not data:
raise ValueError("Empty data")
return True
""")
# Create subdirectory
auth_dir = src_dir / "auth"
auth_dir.mkdir()
(auth_dir / "handler.py").write_text("""
from typing import Optional
class AuthHandler:
def __init__(self):
self.token = None
def authenticate(self, credentials: dict) -> bool:
# Authenticate user
return True
def logout(self):
self.token = None
""")
# Create a .gitignore
(Path(tmpdir) / ".gitignore").write_text("""
__pycache__/
*.pyc
.env
""")
# Create ignored directory
cache_dir = Path(tmpdir) / "__pycache__"
cache_dir.mkdir()
(cache_dir / "cached.pyc").write_text("cached content")
yield tmpdir
class TestGrepSearch:
"""Tests for grep_search function."""
def test_grep_simple_pattern(self, temp_workspace):
"""Test grep with simple pattern."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "authenticate")
assert len(results) > 0
# Should find in main.py and handler.py
paths = [r["path"] for r in results]
assert any("main.py" in p for p in paths)
assert any("handler.py" in p for p in paths)
def test_grep_regex_pattern(self, temp_workspace):
"""Test grep with regex pattern."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, r"def \w+\(", is_regex=True)
assert len(results) > 0
# Should find function definitions
def test_grep_case_insensitive(self, temp_workspace):
"""Test grep case insensitive search."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "AUTHENTICATE", case_sensitive=False)
assert len(results) > 0
def test_grep_case_sensitive(self, temp_workspace):
"""Test grep case sensitive search."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "AUTHENTICATE", case_sensitive=True)
assert len(results) == 0 # No uppercase AUTHENTICATE exists
def test_grep_with_file_pattern(self, temp_workspace):
"""Test grep with file pattern filter."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "def", include_patterns=["*.py"])
assert len(results) > 0
# All results should be .py files
for r in results:
assert r["path"].endswith(".py")
def test_grep_with_exclude_pattern(self, temp_workspace):
"""Test grep with exclude pattern."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "def", exclude_patterns=["**/auth/*"])
# Should not include auth directory files
for r in results:
assert "auth" not in r["path"]
def test_grep_max_results(self, temp_workspace):
"""Test grep with max results limit."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "def", max_results=2)
assert len(results) <= 2
def test_grep_no_matches(self, temp_workspace):
"""Test grep with no matches."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "nonexistent_pattern_xyz")
assert len(results) == 0
def test_grep_respects_gitignore(self, temp_workspace):
"""Test grep respects .gitignore patterns."""
from praisonaiagents.context.fast.search_tools import grep_search
results = grep_search(temp_workspace, "cached", respect_gitignore=True)
# Should not find content in __pycache__
for r in results:
assert "__pycache__" not in r["path"]
class TestGlobSearch:
"""Tests for glob_search function."""
def test_glob_all_python_files(self, temp_workspace):
"""Test glob to find all Python files."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "**/*.py")
assert len(results) >= 3 # main.py, utils.py, handler.py
for r in results:
assert r["path"].endswith(".py")
def test_glob_specific_directory(self, temp_workspace):
"""Test glob in specific directory."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "src/auth/*.py")
assert len(results) == 1
assert "handler.py" in results[0]["path"]
def test_glob_by_name(self, temp_workspace):
"""Test glob by filename."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "**/main.py")
assert len(results) == 1
assert "main.py" in results[0]["path"]
def test_glob_no_matches(self, temp_workspace):
"""Test glob with no matches."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "**/*.java")
assert len(results) == 0
def test_glob_max_results(self, temp_workspace):
"""Test glob with max results."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "**/*.py", max_results=2)
assert len(results) <= 2
def test_glob_respects_gitignore(self, temp_workspace):
"""Test glob respects .gitignore."""
from praisonaiagents.context.fast.search_tools import glob_search
results = glob_search(temp_workspace, "**/*.pyc", respect_gitignore=True)
assert len(results) == 0 # .pyc is in .gitignore
class TestReadFile:
"""Tests for read_file function."""
def test_read_entire_file(self, temp_workspace):
"""Test reading entire file."""
from praisonaiagents.context.fast.search_tools import read_file
result = read_file(os.path.join(temp_workspace, "src", "main.py"))
assert result["success"] is True
assert "def main():" in result["content"]
assert result["total_lines"] > 0
def test_read_file_with_line_range(self, temp_workspace):
"""Test reading specific line range."""
from praisonaiagents.context.fast.search_tools import read_file
result = read_file(
os.path.join(temp_workspace, "src", "main.py"),
start_line=1,
end_line=3
)
assert result["success"] is True
assert result["start_line"] == 1
assert result["end_line"] == 3
lines = result["content"].split("\n")
assert len(lines) <= 3
def test_read_nonexistent_file(self, temp_workspace):
"""Test reading nonexistent file."""
from praisonaiagents.context.fast.search_tools import read_file
result = read_file(os.path.join(temp_workspace, "nonexistent.py"))
assert result["success"] is False
assert "error" in result
def test_read_file_with_context(self, temp_workspace):
"""Test reading with context lines."""
from praisonaiagents.context.fast.search_tools import read_file
result = read_file(
os.path.join(temp_workspace, "src", "main.py"),
start_line=5,
end_line=5,
context_lines=2
)
assert result["success"] is True
# Should include 2 lines before and after line 5
class TestListDirectory:
"""Tests for list_directory function."""
def test_list_directory(self, temp_workspace):
"""Test listing directory contents."""
from praisonaiagents.context.fast.search_tools import list_directory
result = list_directory(temp_workspace)
assert result["success"] is True
assert len(result["entries"]) > 0
# Should contain src directory
names = [e["name"] for e in result["entries"]]
assert "src" in names
def test_list_directory_recursive(self, temp_workspace):
"""Test listing directory recursively."""
from praisonaiagents.context.fast.search_tools import list_directory
result = list_directory(temp_workspace, recursive=True)
assert result["success"] is True
# Should find files in subdirectories
paths = [e["path"] for e in result["entries"]]
assert any("main.py" in p for p in paths)
assert any("handler.py" in p for p in paths)
def test_list_directory_max_depth(self, temp_workspace):
"""Test listing with max depth."""
from praisonaiagents.context.fast.search_tools import list_directory
result = list_directory(temp_workspace, recursive=True, max_depth=1)
assert result["success"] is True
# Should not find files in auth subdirectory (depth 2)
paths = [e["path"] for e in result["entries"]]
assert not any("handler.py" in p for p in paths)
def test_list_nonexistent_directory(self, temp_workspace):
"""Test listing nonexistent directory."""
from praisonaiagents.context.fast.search_tools import list_directory
result = list_directory(os.path.join(temp_workspace, "nonexistent"))
assert result["success"] is False
def test_list_directory_respects_gitignore(self, temp_workspace):
"""Test listing respects .gitignore."""
from praisonaiagents.context.fast.search_tools import list_directory
result = list_directory(temp_workspace, recursive=True, respect_gitignore=True)
# Should not include __pycache__
paths = [e["path"] for e in result["entries"]]
assert not any("__pycache__" in p for p in paths)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,323 @@
"""
Tests for MCP HTTP Stream Transport features.
Tests the HTTPStreamTransport implementation including:
- Protocol version header (Mcp-Protocol-Version)
- Session management (Mcp-Session-Id)
- Resumability (Last-Event-ID, retry delay)
- Session termination (HTTP DELETE)
- SSE event parsing
"""
import pytest
class TestHTTPStreamTransportInit:
"""Test HTTPStreamTransport initialization."""
def test_transport_has_protocol_version_header(self):
"""Test that transport includes Mcp-Protocol-Version header."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert 'Mcp-Protocol-Version' in transport.headers
assert transport.headers['Mcp-Protocol-Version'] == '2025-03-26'
def test_transport_default_protocol_version(self):
"""Test default protocol version for backward compatibility."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
assert HTTPStreamTransport.DEFAULT_PROTOCOL_VERSION == '2025-03-26'
def test_transport_custom_protocol_version(self):
"""Test custom protocol version via options."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport(
'https://api.example.com/mcp',
options={'protocol_version': '2025-11-25'}
)
assert transport.headers['Mcp-Protocol-Version'] == '2025-11-25'
def test_transport_has_accept_header(self):
"""Test that transport includes correct Accept header."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert 'Accept' in transport.headers
assert 'application/json' in transport.headers['Accept']
assert 'text/event-stream' in transport.headers['Accept']
def test_transport_has_session_id_when_provided(self):
"""Test that transport includes session ID when provided."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport(
'https://api.example.com/mcp',
session_id='test-session-123'
)
assert 'Mcp-Session-Id' in transport.headers
assert transport.headers['Mcp-Session-Id'] == 'test-session-123'
def test_transport_no_session_id_when_not_provided(self):
"""Test that transport doesn't include session ID when not provided."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert 'Mcp-Session-Id' not in transport.headers
class TestHTTPStreamTransportResumability:
"""Test HTTPStreamTransport resumability features."""
def test_transport_has_last_event_id_tracking(self):
"""Test that transport tracks last event ID."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert hasattr(transport, 'last_event_id')
assert transport.last_event_id is None
def test_transport_has_retry_delay_tracking(self):
"""Test that transport tracks retry delay."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert hasattr(transport, '_retry_delay_ms')
assert transport._retry_delay_ms == 3000 # Default 3 seconds
def test_transport_updates_last_event_id(self):
"""Test that last_event_id can be updated."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
transport.last_event_id = 'event-123'
assert transport.last_event_id == 'event-123'
def test_transport_updates_retry_delay(self):
"""Test that retry delay can be updated."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
transport._retry_delay_ms = 5000
assert transport._retry_delay_ms == 5000
class TestHTTPStreamTransportSessionTermination:
"""Test HTTPStreamTransport session termination."""
def test_transport_has_terminate_session_method(self):
"""Test that transport has terminate_session method."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
assert hasattr(transport, 'terminate_session')
assert callable(transport.terminate_session)
@pytest.mark.asyncio
async def test_terminate_session_returns_false_without_session(self):
"""Test that terminate_session returns False without session."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport('https://api.example.com/mcp')
# No session, should return False
result = await transport.terminate_session()
assert result is False
@pytest.mark.asyncio
async def test_terminate_session_returns_false_without_client_session(self):
"""Test that terminate_session returns False without aiohttp session."""
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
transport = HTTPStreamTransport(
'https://api.example.com/mcp',
session_id='test-session'
)
# _session is None (not initialized)
result = await transport.terminate_session()
assert result is False
class TestHTTPStreamTransportAutoDetection:
"""Test transport auto-detection in MCP class."""
def test_http_url_uses_http_stream_transport(self):
"""Test that HTTP URLs use HTTPStreamTransport."""
from praisonaiagents.mcp.mcp_transport import get_transport_type
assert get_transport_type('http://localhost:8080/mcp') == 'http_stream'
assert get_transport_type('https://api.example.com/mcp') == 'http_stream'
def test_sse_url_uses_sse_transport(self):
"""Test that SSE URLs use SSE transport."""
from praisonaiagents.mcp.mcp_transport import get_transport_type
assert get_transport_type('http://localhost:8080/sse') == 'sse'
assert get_transport_type('https://api.example.com/sse') == 'sse'
def test_websocket_url_uses_websocket_transport(self):
"""Test that WebSocket URLs use WebSocket transport."""
from praisonaiagents.mcp.mcp_transport import get_transport_type
assert get_transport_type('ws://localhost:8080/mcp') == 'websocket'
assert get_transport_type('wss://api.example.com/mcp') == 'websocket'
def test_command_string_uses_stdio_transport(self):
"""Test that command strings use stdio transport."""
from praisonaiagents.mcp.mcp_transport import get_transport_type
assert get_transport_type('npx @modelcontextprotocol/server-memory') == 'stdio'
assert get_transport_type('python3 server.py') == 'stdio'
class TestSSEEventParsing:
"""Test SSE event parsing for resumability."""
def test_parse_sse_event_with_id(self):
"""Test parsing SSE event with id field."""
from praisonaiagents.mcp.mcp_session import parse_sse_event
event_text = "id: event-123\ndata: {\"test\": true}"
result = parse_sse_event(event_text)
assert result['id'] == 'event-123'
assert result['data'] == '{"test": true}'
def test_parse_sse_event_with_retry(self):
"""Test parsing SSE event with retry field."""
from praisonaiagents.mcp.mcp_session import parse_sse_event
event_text = "retry: 5000\ndata: {\"test\": true}"
result = parse_sse_event(event_text)
assert result['retry'] == 5000
assert result['data'] == '{"test": true}'
def test_parse_sse_event_with_all_fields(self):
"""Test parsing SSE event with all fields."""
from praisonaiagents.mcp.mcp_session import parse_sse_event
event_text = "id: event-456\nretry: 3000\ndata: {\"jsonrpc\": \"2.0\"}"
result = parse_sse_event(event_text)
assert result['id'] == 'event-456'
assert result['retry'] == 3000
assert result['data'] == '{"jsonrpc": "2.0"}'
def test_parse_sse_event_multiline_data(self):
"""Test parsing SSE event with multiline data."""
from praisonaiagents.mcp.mcp_session import parse_sse_event
event_text = "data: line1\ndata: line2"
result = parse_sse_event(event_text)
assert result['data'] == 'line1\nline2'
class TestResumabilityManager:
"""Test ResumabilityManager for SSE stream recovery."""
def test_manager_tracks_event_ids(self):
"""Test that manager tracks event IDs per stream."""
from praisonaiagents.mcp.mcp_session import ResumabilityManager
manager = ResumabilityManager()
manager.set_last_event_id('stream1', 'event-100')
manager.set_last_event_id('stream2', 'event-200')
assert manager.get_last_event_id('stream1') == 'event-100'
assert manager.get_last_event_id('stream2') == 'event-200'
def test_manager_returns_none_for_unknown_stream(self):
"""Test that manager returns None for unknown stream."""
from praisonaiagents.mcp.mcp_session import ResumabilityManager
manager = ResumabilityManager()
assert manager.get_last_event_id('unknown') is None
def test_manager_generates_resume_headers(self):
"""Test that manager generates Last-Event-ID header."""
from praisonaiagents.mcp.mcp_session import ResumabilityManager
manager = ResumabilityManager()
manager.set_last_event_id('stream1', 'event-123')
headers = manager.get_resume_headers('stream1')
assert 'Last-Event-ID' in headers
assert headers['Last-Event-ID'] == 'event-123'
def test_manager_tracks_retry_delay(self):
"""Test that manager tracks retry delay."""
from praisonaiagents.mcp.mcp_session import ResumabilityManager
manager = ResumabilityManager()
manager.set_retry_delay(5000)
assert manager.get_retry_delay() == 5000
def test_manager_default_retry_delay(self):
"""Test default retry delay."""
from praisonaiagents.mcp.mcp_session import ResumabilityManager
manager = ResumabilityManager()
# Default should be 3000ms
assert manager.get_retry_delay() == 3000
class TestEventStore:
"""Test EventStore for message replay."""
def test_store_events(self):
"""Test storing events."""
from praisonaiagents.mcp.mcp_session import EventStore
store = EventStore(max_events=100)
store.store('stream1', 'event-1', {'data': 'test1'})
store.store('stream1', 'event-2', {'data': 'test2'})
events = store.get_all_events('stream1')
assert len(events) == 2
def test_get_events_after_id(self):
"""Test getting events after specific ID."""
from praisonaiagents.mcp.mcp_session import EventStore
store = EventStore(max_events=100)
store.store('stream1', 'event-1', {'data': 'test1'})
store.store('stream1', 'event-2', {'data': 'test2'})
store.store('stream1', 'event-3', {'data': 'test3'})
events = store.get_events_after('stream1', 'event-1')
assert len(events) == 2
assert events[0]['data'] == 'test2'
assert events[1]['data'] == 'test3'
def test_store_respects_max_events(self):
"""Test that store respects max_events limit."""
from praisonaiagents.mcp.mcp_session import EventStore
store = EventStore(max_events=3)
for i in range(5):
store.store('stream1', f'event-{i}', {'data': f'test{i}'})
events = store.get_all_events('stream1')
assert len(events) == 3
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,390 @@
"""
Test suite for Workflow Per-Step Agent Configuration.
Tests the enhanced workflow system's ability to configure different agents
for each step, including custom roles, goals, backstories, and LLM settings.
"""
import pytest
from unittest.mock import Mock, patch
import sys
import os
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager
)
class TestWorkflowStepAgentFields:
"""Test WorkflowStep dataclass agent-related fields."""
def test_workflow_step_has_agent_config_field(self):
"""WorkflowStep should have agent_config field."""
step = WorkflowStep(
name="test_step",
action="do something",
agent_config={
"role": "Researcher",
"goal": "Find information",
"backstory": "Expert researcher"
}
)
assert hasattr(step, 'agent_config')
assert step.agent_config["role"] == "Researcher"
def test_workflow_step_has_tools_field(self):
"""WorkflowStep should have tools field."""
step = WorkflowStep(
name="test_step",
action="do something",
tools=["search", "calculator"]
)
assert hasattr(step, 'tools')
assert "search" in step.tools
def test_workflow_step_agent_config_default_none(self):
"""agent_config should default to None."""
step = WorkflowStep(name="test_step", action="do something")
assert step.agent_config is None
def test_workflow_step_tools_default_none(self):
"""tools should default to None."""
step = WorkflowStep(name="test_step", action="do something")
assert step.tools is None
def test_workflow_step_to_dict_includes_agent_fields(self):
"""to_dict() should include agent-related fields."""
step = WorkflowStep(
name="test_step",
action="do something",
agent_config={"role": "Writer"},
tools=["grammar_check"]
)
d = step.to_dict()
assert "agent_config" in d
assert "tools" in d
class TestWorkflowDefaultAgentConfig:
"""Test Workflow dataclass default agent configuration."""
def test_workflow_has_default_agent_config(self):
"""Workflow should have default_agent_config field."""
workflow = Workflow(
name="test_workflow",
default_agent_config={
"role": "Assistant",
"goal": "Help with tasks"
}
)
assert hasattr(workflow, 'default_agent_config')
assert workflow.default_agent_config["role"] == "Assistant"
def test_workflow_has_default_llm(self):
"""Workflow should have default_llm field."""
workflow = Workflow(
name="test_workflow",
default_llm="gpt-4o-mini"
)
assert hasattr(workflow, 'default_llm')
assert workflow.default_llm == "gpt-4o-mini"
def test_workflow_to_dict_includes_defaults(self):
"""to_dict() should include default agent config fields."""
workflow = Workflow(
name="test_workflow",
default_agent_config={"role": "Helper"},
default_llm="gpt-4o"
)
d = workflow.to_dict()
assert "default_agent_config" in d
assert "default_llm" in d
class TestCreateStepAgent:
"""Test _create_step_agent method."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_create_step_agent_with_config(self):
"""Should create agent from step's agent_config."""
manager = WorkflowManager()
step = WorkflowStep(
name="research",
action="Research topic",
agent_config={
"role": "Researcher",
"goal": "Find comprehensive information",
"backstory": "Expert in research"
}
)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
MockAgent.return_value = Mock()
agent = manager._create_step_agent(
step=step,
default_llm="gpt-4o-mini",
verbose=0
)
# Verify Agent was called with config
MockAgent.assert_called_once()
call_kwargs = MockAgent.call_args[1]
assert call_kwargs["role"] == "Researcher"
assert call_kwargs["goal"] == "Find comprehensive information"
def test_create_step_agent_uses_default_agent(self):
"""Should return default_agent when no agent_config."""
manager = WorkflowManager()
step = WorkflowStep(
name="simple_step",
action="Do something"
)
default_agent = Mock()
agent = manager._create_step_agent(
step=step,
default_agent=default_agent,
default_llm="gpt-4o-mini"
)
assert agent == default_agent
def test_create_step_agent_includes_tools(self):
"""Should include tools in agent config."""
manager = WorkflowManager()
mock_tool = Mock()
step = WorkflowStep(
name="research",
action="Research topic",
agent_config={"role": "Researcher"},
tools=[mock_tool]
)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
MockAgent.return_value = Mock()
manager._create_step_agent(
step=step,
default_llm="gpt-4o-mini"
)
call_kwargs = MockAgent.call_args[1]
assert "tools" in call_kwargs
assert mock_tool in call_kwargs["tools"]
def test_create_step_agent_includes_memory(self):
"""Should include memory in agent config."""
manager = WorkflowManager()
step = WorkflowStep(
name="research",
action="Research topic",
agent_config={"role": "Researcher"}
)
mock_memory = Mock()
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
MockAgent.return_value = Mock()
manager._create_step_agent(
step=step,
memory=mock_memory
)
call_kwargs = MockAgent.call_args[1]
assert "memory" in call_kwargs
assert call_kwargs["memory"] == mock_memory
class TestExecuteWithAgents:
"""Test execute() with per-step agent configuration."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_execute_with_default_agent(self):
"""Execute should use default_agent when provided."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
# Create mock default agent
mock_agent = Mock()
mock_agent.chat = Mock(return_value="Agent response")
result = manager.execute(
"test_workflow",
default_agent=mock_agent
)
assert result["success"]
assert mock_agent.chat.call_count == 2
def test_execute_with_step_agent_config(self):
"""Execute should create agent from step's agent_config."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="research",
action="Research AI",
agent_config={
"role": "Researcher",
"goal": "Find information"
}
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent_instance = Mock()
mock_agent_instance.chat = Mock(return_value="Research results")
MockAgent.return_value = mock_agent_instance
result = manager.execute(
"test_workflow",
default_llm="gpt-4o-mini"
)
assert result["success"]
MockAgent.assert_called_once()
def test_execute_with_executor_overrides_agent(self):
"""Executor function should override agent creation."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Should not be used"}
)
]
)
self._register_workflow(manager, workflow)
executor_called = [False]
def custom_executor(action):
executor_called[0] = True
return "Custom executor result"
result = manager.execute(
"test_workflow",
executor=custom_executor
)
assert result["success"]
assert executor_called[0]
def test_execute_fails_without_executor_or_agent(self):
"""Execute should fail if no executor or agent provided."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
result = manager.execute("test_workflow")
assert not result["success"]
assert "No executor available" in result.get("error", "")
class TestWorkflowLevelDefaults:
"""Test workflow-level default configurations."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_workflow_default_llm_used(self):
"""Workflow's default_llm should be used for agent creation."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
default_llm="gpt-4o",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Helper"}
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent_instance = Mock()
mock_agent_instance.chat = Mock(return_value="Response")
MockAgent.return_value = mock_agent_instance
manager.execute("test_workflow")
call_kwargs = MockAgent.call_args[1]
assert call_kwargs.get("llm") == "gpt-4o"
def test_execute_default_llm_overrides_workflow(self):
"""Execute's default_llm should override workflow's default_llm."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
default_llm="gpt-3.5-turbo",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Helper"}
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent_instance = Mock()
mock_agent_instance.chat = Mock(return_value="Response")
MockAgent.return_value = mock_agent_instance
manager.execute("test_workflow", default_llm="gpt-4o-mini")
call_kwargs = MockAgent.call_args[1]
assert call_kwargs.get("llm") == "gpt-4o-mini"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,202 @@
"""
Test suite for Workflow Async Support.
Tests the aexecute() async method for workflow execution.
"""
import pytest
import asyncio
from unittest.mock import Mock, AsyncMock, patch
import sys
import os
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager
)
class TestAexecuteMethod:
"""Test aexecute() async method."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
@pytest.mark.asyncio
async def test_aexecute_exists(self):
"""WorkflowManager should have aexecute method."""
manager = WorkflowManager()
assert hasattr(manager, 'aexecute')
assert asyncio.iscoroutinefunction(manager.aexecute)
@pytest.mark.asyncio
async def test_aexecute_with_sync_executor(self):
"""aexecute should work with sync executor."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
def sync_executor(action):
return f"Sync result for {action}"
result = await manager.aexecute("test_workflow", executor=sync_executor)
assert result["success"]
assert len(result["results"]) == 2
@pytest.mark.asyncio
async def test_aexecute_with_async_executor(self):
"""aexecute should work with async executor."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
async def async_executor(action):
await asyncio.sleep(0.01) # Simulate async operation
return f"Async result for {action}"
result = await manager.aexecute("test_workflow", executor=async_executor)
assert result["success"]
assert len(result["results"]) == 2
assert "Async result" in result["results"][0]["output"]
@pytest.mark.asyncio
async def test_aexecute_context_passing(self):
"""aexecute should pass context between steps."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Generate data"),
WorkflowStep(name="step2", action="Process {{previous_output}}")
]
)
self._register_workflow(manager, workflow)
received_actions = []
async def async_executor(action):
received_actions.append(action)
if "Generate" in action:
return "DATA_ABC"
return "Processed"
result = await manager.aexecute("test_workflow", executor=async_executor)
assert result["success"]
# Step 2 should have received context
assert "DATA_ABC" in received_actions[1] or "previous_output" in received_actions[1]
@pytest.mark.asyncio
async def test_aexecute_workflow_not_found(self):
"""aexecute should return error for non-existent workflow."""
manager = WorkflowManager()
manager._loaded = True
result = await manager.aexecute("nonexistent_workflow")
assert not result["success"]
assert "not found" in result.get("error", "")
@pytest.mark.asyncio
async def test_aexecute_with_default_agent(self):
"""aexecute should work with default_agent."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
mock_agent = Mock()
mock_agent.chat = Mock(return_value="Agent response")
result = await manager.aexecute(
"test_workflow",
default_agent=mock_agent
)
assert result["success"]
mock_agent.chat.assert_called()
@pytest.mark.asyncio
async def test_aexecute_with_async_agent(self):
"""aexecute should use achat if available on agent."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Helper"}
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent = Mock()
mock_agent.achat = AsyncMock(return_value="Async agent response")
MockAgent.return_value = mock_agent
result = await manager.aexecute(
"test_workflow",
default_llm="gpt-4o-mini"
)
assert result["success"]
mock_agent.achat.assert_called()
@pytest.mark.asyncio
async def test_aexecute_returns_variables(self):
"""aexecute should return accumulated variables."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
variables={"initial": "value"},
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
async def async_executor(action):
return "Output"
result = await manager.aexecute("test_workflow", executor=async_executor)
assert "variables" in result
assert "previous_output" in result["variables"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,353 @@
"""
Test suite for Workflow Context Passing functionality.
Tests the enhanced workflow system's ability to pass context between steps,
including output accumulation, variable substitution, and selective context inclusion.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
import sys
import os
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager,
create_workflow_manager
)
class TestWorkflowStepContextFields:
"""Test WorkflowStep dataclass context-related fields."""
def test_workflow_step_has_context_from_field(self):
"""WorkflowStep should have context_from field."""
step = WorkflowStep(
name="test_step",
action="do something",
context_from=["step1", "step2"]
)
assert hasattr(step, 'context_from')
assert step.context_from == ["step1", "step2"]
def test_workflow_step_has_retain_full_context_field(self):
"""WorkflowStep should have retain_full_context field with default True."""
step = WorkflowStep(name="test_step", action="do something")
assert hasattr(step, 'retain_full_context')
assert step.retain_full_context == True
def test_workflow_step_has_output_variable_field(self):
"""WorkflowStep should have output_variable field."""
step = WorkflowStep(
name="test_step",
action="do something",
output_variable="research_result"
)
assert hasattr(step, 'output_variable')
assert step.output_variable == "research_result"
def test_workflow_step_to_dict_includes_new_fields(self):
"""to_dict() should include context-related fields."""
step = WorkflowStep(
name="test_step",
action="do something",
context_from=["step1"],
retain_full_context=False,
output_variable="result"
)
d = step.to_dict()
assert "context_from" in d
assert "retain_full_context" in d
assert "output_variable" in d
class TestContextPassing:
"""Test context passing between workflow steps."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_step_receives_previous_step_output(self):
"""Step 2 should receive Step 1's output as context."""
manager = WorkflowManager()
# Create a simple workflow with 2 steps
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Generate data"),
WorkflowStep(name="step2", action="Process {{previous_output}}")
]
)
self._register_workflow(manager, workflow)
# Track what each step receives
received_actions = []
def mock_executor(action):
received_actions.append(action)
if "Generate" in action:
return "Generated data: ABC123"
return f"Processed: {action}"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
assert len(result["results"]) == 2
# Step 2 should have received the output from step 1
assert "ABC123" in received_actions[1] or "previous_output" in received_actions[1]
def test_context_accumulates_across_steps(self):
"""Each step should have access to all previous outputs when retain_full_context=True."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="First action", retain_full_context=True),
WorkflowStep(name="step2", action="Second action", retain_full_context=True),
WorkflowStep(name="step3", action="Third with context: {{step1_output}} and {{step2_output}}", retain_full_context=True)
]
)
self._register_workflow(manager, workflow)
outputs = ["Output1", "Output2", "Output3"]
call_count = [0]
def mock_executor(action):
idx = call_count[0]
call_count[0] += 1
return outputs[idx]
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
assert len(result["results"]) == 3
# All outputs should be stored
assert result["results"][0]["output"] == "Output1"
assert result["results"][1]["output"] == "Output2"
assert result["results"][2]["output"] == "Output3"
def test_context_from_specific_steps(self):
"""Step should only receive context from specified steps via context_from."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="research", action="Research topic"),
WorkflowStep(name="analysis", action="Analyze data"),
WorkflowStep(
name="summary",
action="Summarize based on: {{research_output}}",
context_from=["research"], # Only include research, not analysis
retain_full_context=False
)
]
)
self._register_workflow(manager, workflow)
received_actions = []
def mock_executor(action):
received_actions.append(action)
if "Research" in action:
return "Research findings: XYZ"
elif "Analyze" in action:
return "Analysis results: 123"
return "Summary complete"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
# The summary step should have research output but not analysis
# (This tests the context_from filtering)
def test_output_variable_substitution(self):
"""Output should be stored in specified variable name."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Generate report",
output_variable="report_data"
),
WorkflowStep(
name="step2",
action="Use report: {{report_data}}"
)
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
if "Generate" in action:
return "Report content here"
return f"Used: {action}"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
def test_retain_full_context_false_only_last_step(self):
"""When retain_full_context=False, only include last step's output."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="First"),
WorkflowStep(name="step2", action="Second"),
WorkflowStep(
name="step3",
action="Third with {{previous_output}}",
retain_full_context=False
)
]
)
self._register_workflow(manager, workflow)
received_actions = []
def mock_executor(action):
received_actions.append(action)
if "First" in action:
return "First output"
elif "Second" in action:
return "Second output"
return "Third output"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
# Step 3 should only have step 2's output, not step 1's
class TestContextVariableSubstitution:
"""Test variable substitution with step outputs."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_previous_output_variable(self):
"""{{previous_output}} should be replaced with last step's output."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Generate"),
WorkflowStep(name="step2", action="Process: {{previous_output}}")
]
)
self._register_workflow(manager, workflow)
received_actions = []
def mock_executor(action):
received_actions.append(action)
if "Generate" in action:
return "DATA_123"
return "Done"
result = manager.execute("test_workflow", executor=mock_executor)
# Check that previous_output was substituted
assert "DATA_123" in received_actions[1] or "previous_output" in received_actions[1]
def test_named_step_output_variable(self):
"""{{step_name_output}} should be replaced with that step's output."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="research", action="Research topic"),
WorkflowStep(name="write", action="Write about {{research_output}}")
]
)
self._register_workflow(manager, workflow)
received_actions = []
def mock_executor(action):
received_actions.append(action)
if "Research" in action:
return "Research findings"
return "Article written"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
class TestWorkflowExecuteResults:
"""Test that execute() returns proper results with context info."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_results_include_step_outputs(self):
"""Results should include output from each step."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return f"Output for {action}"
result = manager.execute("test_workflow", executor=mock_executor)
assert result["success"] == True
assert len(result["results"]) == 2
assert result["results"][0]["output"] is not None
assert result["results"][1]["output"] is not None
def test_results_include_accumulated_context(self):
"""Results should include accumulated context information."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return f"Output for {action}"
result = manager.execute("test_workflow", executor=mock_executor)
# Result should have context or variables with accumulated outputs
assert "results" in result
assert result["success"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,341 @@
"""
Test suite for Workflow Markdown Parser.
Tests the enhanced markdown parser for parsing agent config, tools,
context_from, and other new fields from workflow markdown files.
"""
import pytest
import tempfile
import os
import sys
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager
)
class TestParseAgentConfig:
"""Test parsing agent configuration from markdown."""
def test_parse_agent_config_block(self):
"""Should parse agent config from ```agent block."""
manager = WorkflowManager()
body = '''
## Step 1: Research
Research the topic.
```agent
role: Researcher
goal: Find comprehensive information
backstory: Expert researcher with 10 years experience
llm: gpt-4o
```
```action
Search for information about AI
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].agent_config is not None
assert steps[0].agent_config["role"] == "Researcher"
assert steps[0].agent_config["goal"] == "Find comprehensive information"
assert steps[0].agent_config["llm"] == "gpt-4o"
def test_parse_agent_config_helper(self):
"""_parse_agent_config should parse key-value pairs."""
manager = WorkflowManager()
agent_str = '''
role: Writer
goal: Write engaging content
verbose: true
max_iter: 10
'''
config = manager._parse_agent_config(agent_str)
assert config["role"] == "Writer"
assert config["goal"] == "Write engaging content"
assert config["verbose"] is True
assert config["max_iter"] == 10
class TestParseToolsBlock:
"""Test parsing tools from markdown."""
def test_parse_tools_block(self):
"""Should parse tools from ```tools block."""
manager = WorkflowManager()
body = '''
## Step 1: Search
Search for information.
```tools
tavily_search
web_browser
calculator
```
```action
Search for AI trends
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].tools is not None
assert len(steps[0].tools) == 3
assert "tavily_search" in steps[0].tools
assert "web_browser" in steps[0].tools
assert "calculator" in steps[0].tools
class TestParseContextFrom:
"""Test parsing context_from from markdown."""
def test_parse_context_from(self):
"""Should parse context_from directive."""
manager = WorkflowManager()
body = '''
## Step 1: Research
Research the topic.
```action
Research AI
```
## Step 2: Analyze
Analyze the research.
```action
Analyze data
```
## Step 3: Summary
Summarize findings.
context_from: [Research, Analyze]
```action
Write summary
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 3
assert steps[2].context_from is not None
assert "Research" in steps[2].context_from
assert "Analyze" in steps[2].context_from
class TestParseRetainFullContext:
"""Test parsing retain_full_context from markdown."""
def test_parse_retain_full_context_false(self):
"""Should parse retain_full_context: false."""
manager = WorkflowManager()
body = '''
## Step 1: Process
Process data.
retain_full_context: false
```action
Process the data
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].retain_full_context is False
def test_parse_retain_full_context_true(self):
"""Should parse retain_full_context: true."""
manager = WorkflowManager()
body = '''
## Step 1: Process
Process data.
retain_full_context: true
```action
Process the data
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].retain_full_context is True
def test_retain_full_context_default_true(self):
"""retain_full_context should default to True."""
manager = WorkflowManager()
body = '''
## Step 1: Process
Process data.
```action
Process the data
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].retain_full_context is True
class TestParseOutputVariable:
"""Test parsing output_variable from markdown."""
def test_parse_output_variable(self):
"""Should parse output_variable directive."""
manager = WorkflowManager()
body = '''
## Step 1: Generate
Generate report.
output_variable: report_data
```action
Generate the report
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 1
assert steps[0].output_variable == "report_data"
class TestLoadWorkflowWithNewFields:
"""Test loading workflow files with new fields."""
def test_load_workflow_with_default_llm(self):
"""Should load workflow with default_llm from frontmatter."""
manager = WorkflowManager()
content = '''---
name: Test Workflow
description: A test workflow
default_llm: gpt-4o
planning: true
planning_llm: gpt-4o-mini
---
## Step 1: Test
Test step.
```action
Do something
```
'''
# Create temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
f.write(content)
temp_path = f.name
try:
from pathlib import Path
workflow = manager._load_workflow(Path(temp_path))
assert workflow is not None
assert workflow.default_llm == "gpt-4o"
assert workflow.planning is True
assert workflow.planning_llm == "gpt-4o-mini"
finally:
os.unlink(temp_path)
class TestCompleteWorkflowParsing:
"""Test complete workflow parsing with all new features."""
def test_parse_complete_workflow(self):
"""Should parse a complete workflow with all new features."""
manager = WorkflowManager()
body = '''
## Step 1: Research
Research the topic thoroughly.
```agent
role: Researcher
goal: Find comprehensive information
backstory: Expert researcher
```
```tools
tavily_search
web_browser
```
output_variable: research_data
```action
Search for information about {{topic}}
```
## Step 2: Analyze
Analyze the research findings.
```agent
role: Analyst
goal: Analyze data patterns
```
context_from: [Research]
retain_full_context: false
```action
Analyze: {{research_data}}
```
## Step 3: Write
Write the final report.
```agent
role: Writer
goal: Write engaging content
```
```action
Write report based on analysis
```
'''
steps = manager._parse_steps(body)
assert len(steps) == 3
# Step 1
assert steps[0].name == "Research"
assert steps[0].agent_config is not None
assert steps[0].agent_config["role"] == "Researcher"
assert steps[0].tools is not None
assert "tavily_search" in steps[0].tools
assert steps[0].output_variable == "research_data"
# Step 2
assert steps[1].name == "Analyze"
assert steps[1].agent_config["role"] == "Analyst"
assert steps[1].context_from == ["Research"]
assert steps[1].retain_full_context is False
# Step 3
assert steps[2].name == "Write"
assert steps[2].agent_config["role"] == "Writer"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,359 @@
"""
Test suite for Workflow Planning Mode Integration.
Tests the enhanced workflow system's ability to integrate with planning mode,
including workflow-level planning settings and planning LLM configuration.
"""
import pytest
from unittest.mock import Mock, patch
import sys
import os
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager
)
class TestWorkflowPlanningFields:
"""Test Workflow dataclass planning-related fields."""
def test_workflow_has_planning_field(self):
"""Workflow should have planning field."""
workflow = Workflow(
name="test_workflow",
planning=True
)
assert hasattr(workflow, 'planning')
assert workflow.planning is True
def test_workflow_planning_default_false(self):
"""Workflow planning should default to False."""
workflow = Workflow(name="test_workflow")
assert workflow.planning is False
def test_workflow_has_planning_llm_field(self):
"""Workflow should have planning_llm field."""
workflow = Workflow(
name="test_workflow",
planning_llm="gpt-4o"
)
assert hasattr(workflow, 'planning_llm')
assert workflow.planning_llm == "gpt-4o"
def test_workflow_planning_llm_default_none(self):
"""Workflow planning_llm should default to None."""
workflow = Workflow(name="test_workflow")
assert workflow.planning_llm is None
def test_workflow_to_dict_includes_planning_fields(self):
"""to_dict() should include planning-related fields."""
workflow = Workflow(
name="test_workflow",
planning=True,
planning_llm="gpt-4o"
)
d = workflow.to_dict()
assert "planning" in d
assert "planning_llm" in d
assert d["planning"] is True
assert d["planning_llm"] == "gpt-4o"
class TestWorkflowMemoryFields:
"""Test Workflow dataclass memory-related fields."""
def test_workflow_has_memory_config_field(self):
"""Workflow should have memory_config field."""
workflow = Workflow(
name="test_workflow",
memory_config={
"provider": "rag",
"use_embedding": True
}
)
assert hasattr(workflow, 'memory_config')
assert workflow.memory_config["provider"] == "rag"
def test_workflow_memory_config_default_none(self):
"""Workflow memory_config should default to None."""
workflow = Workflow(name="test_workflow")
assert workflow.memory_config is None
def test_workflow_to_dict_includes_memory_config(self):
"""to_dict() should include memory_config field."""
workflow = Workflow(
name="test_workflow",
memory_config={"provider": "sqlite"}
)
d = workflow.to_dict()
assert "memory_config" in d
class TestExecutePlanningParameter:
"""Test execute() planning parameter."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_execute_accepts_planning_parameter(self):
"""Execute should accept planning parameter."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return "Result"
# Should not raise error
result = manager.execute(
"test_workflow",
executor=mock_executor,
planning=True
)
assert result["success"]
def test_workflow_planning_used_when_not_overridden(self):
"""Workflow's planning setting should be used when not overridden."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
planning=True, # Workflow-level planning enabled
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return "Result"
# Execute without planning parameter - should use workflow's setting
result = manager.execute(
"test_workflow",
executor=mock_executor
)
assert result["success"]
class TestExecuteMemoryParameter:
"""Test execute() memory parameter."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_execute_accepts_memory_parameter(self):
"""Execute should accept memory parameter."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
mock_memory = Mock()
def mock_executor(action):
return "Result"
# Should not raise error
result = manager.execute(
"test_workflow",
executor=mock_executor,
memory=mock_memory
)
assert result["success"]
def test_memory_passed_to_step_agent(self):
"""Memory should be passed to created step agents."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Helper"}
)
]
)
self._register_workflow(manager, workflow)
mock_memory = Mock()
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent = Mock()
mock_agent.chat = Mock(return_value="Response")
MockAgent.return_value = mock_agent
manager.execute(
"test_workflow",
memory=mock_memory,
default_llm="gpt-4o-mini"
)
call_kwargs = MockAgent.call_args[1]
assert "memory" in call_kwargs
assert call_kwargs["memory"] == mock_memory
class TestExecuteStreamParameter:
"""Test execute() stream parameter."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_execute_accepts_stream_parameter(self):
"""Execute should accept stream parameter."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return "Result"
# Should not raise error
result = manager.execute(
"test_workflow",
executor=mock_executor,
stream=True
)
assert result["success"]
class TestExecuteVerboseParameter:
"""Test execute() verbose parameter."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_execute_accepts_verbose_parameter(self):
"""Execute should accept verbose parameter."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(name="step1", action="Action 1")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return "Result"
# Should not raise error
result = manager.execute(
"test_workflow",
executor=mock_executor,
verbose=2
)
assert result["success"]
def test_verbose_passed_to_step_agent(self):
"""Verbose level should be passed to created step agents."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
agent_config={"role": "Helper"}
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent = Mock()
mock_agent.chat = Mock(return_value="Response")
MockAgent.return_value = mock_agent
manager.execute(
"test_workflow",
verbose=3,
default_llm="gpt-4o-mini"
)
call_kwargs = MockAgent.call_args[1]
assert "verbose" in call_kwargs
assert call_kwargs["verbose"] == 3
class TestExecuteResultsWithVariables:
"""Test that execute() returns variables in results."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_results_include_variables(self):
"""Results should include accumulated variables."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
variables={"initial": "value"},
steps=[
WorkflowStep(name="step1", action="Action 1"),
WorkflowStep(name="step2", action="Action 2")
]
)
self._register_workflow(manager, workflow)
def mock_executor(action):
return f"Output for {action}"
result = manager.execute("test_workflow", executor=mock_executor)
assert "variables" in result
assert "initial" in result["variables"]
# Should also have step outputs as variables
assert "previous_output" in result["variables"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,306 @@
"""
Test suite for Workflow Per-Step Tools Support.
Tests the enhanced workflow system's ability to configure different tools
for each step, including tool execution and tool results in context.
"""
import pytest
from unittest.mock import Mock, patch
import sys
import os
# Add the package to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.memory.workflows import (
WorkflowStep,
Workflow,
WorkflowManager
)
class TestWorkflowStepToolsField:
"""Test WorkflowStep tools field."""
def test_workflow_step_tools_accepts_list(self):
"""WorkflowStep should accept a list of tools."""
mock_tool1 = Mock()
mock_tool2 = Mock()
step = WorkflowStep(
name="research",
action="Research topic",
tools=[mock_tool1, mock_tool2]
)
assert step.tools is not None
assert len(step.tools) == 2
assert mock_tool1 in step.tools
assert mock_tool2 in step.tools
def test_workflow_step_tools_accepts_callable(self):
"""WorkflowStep should accept callable tools."""
def search_tool(query):
return f"Results for {query}"
step = WorkflowStep(
name="search",
action="Search for info",
tools=[search_tool]
)
assert step.tools is not None
assert callable(step.tools[0])
def test_workflow_step_tools_in_to_dict(self):
"""to_dict() should include tools field."""
step = WorkflowStep(
name="test",
action="test action",
tools=["tool1", "tool2"]
)
d = step.to_dict()
assert "tools" in d
assert d["tools"] == ["tool1", "tool2"]
class TestToolsPassedToAgent:
"""Test that tools are passed to agent during step execution."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_step_tools_passed_to_agent(self):
"""Step's tools should be passed to created agent."""
manager = WorkflowManager()
mock_tool = Mock()
step = WorkflowStep(
name="research",
action="Research topic",
agent_config={"role": "Researcher"},
tools=[mock_tool]
)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
MockAgent.return_value = Mock()
manager._create_step_agent(step=step, default_llm="gpt-4o-mini")
call_kwargs = MockAgent.call_args[1]
assert "tools" in call_kwargs
assert mock_tool in call_kwargs["tools"]
def test_multiple_tools_passed_to_agent(self):
"""Multiple tools should all be passed to agent."""
manager = WorkflowManager()
tool1 = Mock(name="search")
tool2 = Mock(name="calculator")
tool3 = Mock(name="browser")
step = WorkflowStep(
name="research",
action="Research topic",
agent_config={"role": "Researcher"},
tools=[tool1, tool2, tool3]
)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
MockAgent.return_value = Mock()
manager._create_step_agent(step=step, default_llm="gpt-4o-mini")
call_kwargs = MockAgent.call_args[1]
assert len(call_kwargs["tools"]) == 3
class TestDifferentToolsPerStep:
"""Test different tools for different steps."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_different_steps_different_tools(self):
"""Different steps can have different tools."""
manager = WorkflowManager()
search_tool = Mock(name="search")
write_tool = Mock(name="write")
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="research",
action="Research AI",
agent_config={"role": "Researcher"},
tools=[search_tool]
),
WorkflowStep(
name="write",
action="Write article",
agent_config={"role": "Writer"},
tools=[write_tool]
)
]
)
self._register_workflow(manager, workflow)
created_agents = []
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
def track_agent(**kwargs):
agent = Mock()
agent.chat = Mock(return_value="Response")
agent.tools = kwargs.get("tools", [])
created_agents.append(kwargs)
return agent
MockAgent.side_effect = track_agent
manager.execute("test_workflow", default_llm="gpt-4o-mini")
# Verify different tools for each step
assert len(created_agents) == 2
assert search_tool in created_agents[0]["tools"]
assert write_tool in created_agents[1]["tools"]
def test_step_without_tools(self):
"""Steps without tools should work correctly."""
manager = WorkflowManager()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="think",
action="Think about topic",
agent_config={"role": "Thinker"}
# No tools
)
]
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
mock_agent = Mock()
mock_agent.chat = Mock(return_value="Thoughts")
MockAgent.return_value = mock_agent
result = manager.execute("test_workflow", default_llm="gpt-4o-mini")
assert result["success"]
# tools should not be in kwargs or should be None
call_kwargs = MockAgent.call_args[1]
assert call_kwargs.get("tools") is None or call_kwargs.get("tools") == []
class TestToolsWithExecutor:
"""Test tools behavior when using custom executor."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_executor_ignores_step_tools(self):
"""When executor is provided, step tools are not used for agent creation."""
manager = WorkflowManager()
mock_tool = Mock()
workflow = Workflow(
name="test_workflow",
steps=[
WorkflowStep(
name="step1",
action="Action 1",
tools=[mock_tool] # Tools defined but executor provided
)
]
)
self._register_workflow(manager, workflow)
executor_called = [False]
def custom_executor(action):
executor_called[0] = True
return "Executor result"
# Should not try to create agent since executor is provided
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
result = manager.execute("test_workflow", executor=custom_executor)
assert result["success"]
assert executor_called[0]
MockAgent.assert_not_called()
class TestToolsIntegration:
"""Integration tests for tools in workflow execution."""
@staticmethod
def _register_workflow(manager, workflow):
"""Helper to register workflow and mark as loaded."""
manager._workflows[workflow.name.lower()] = workflow
manager._loaded = True
def test_full_workflow_with_tools(self):
"""Full workflow execution with tools should work."""
manager = WorkflowManager()
# Create mock tools
search_tool = Mock(name="search")
analyze_tool = Mock(name="analyze")
workflow = Workflow(
name="research_workflow",
steps=[
WorkflowStep(
name="search",
action="Search for {{topic}}",
agent_config={"role": "Searcher"},
tools=[search_tool]
),
WorkflowStep(
name="analyze",
action="Analyze: {{previous_output}}",
agent_config={"role": "Analyzer"},
tools=[analyze_tool]
),
WorkflowStep(
name="summarize",
action="Summarize findings",
agent_config={"role": "Summarizer"}
# No tools for final step
)
],
variables={"topic": "AI trends"}
)
self._register_workflow(manager, workflow)
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
def create_mock_agent(**kwargs):
agent = Mock()
agent.chat = Mock(return_value=f"Response from {kwargs.get('role', 'agent')}")
return agent
MockAgent.side_effect = create_mock_agent
result = manager.execute("research_workflow")
assert result["success"]
assert len(result["results"]) == 3
assert MockAgent.call_count == 3
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.189"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+888 -43
View File
@@ -32,42 +32,120 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
## Key Features
- 🤖 Automated AI Agents Creation
- 🔄 Self Reflection AI Agents
- 🧠 Reasoning AI Agents
- 👁️ Multi Modal AI Agents
- 🤝 Multi Agent Collaboration
- 🎭 AI Agent Workflow
- 📚 Add Custom Knowledge
- 🧠 Agents with Short and Long Term Memory
- 📄 Chat with PDF Agents
- 💻 Code Interpreter Agents
- 📚 RAG Agents
- 🤔 Async & Parallel Processing
- 🔄 Auto Agents
- 🔢 Math Agents
- 🎯 Structured Output Agents
- 🔗 LangChain Integrated Agents
- 📞 Callback Agents
- 🤏 Mini AI Agents
- 🛠️ 100+ Custom Tools
- 📄 YAML Configuration
- 💯 100+ LLM Support
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
| 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) |
| 🎭 AI Agent Workflow (Context Passing, Per-Step Agents) | [Example](examples/python/stateful/workflow-state-example.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 Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
| 📚 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) |
| 📋 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) |
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
| ⛓️ 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/cli/docs) |
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
| ⏸️ Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/cli/session) |
| 🔀 Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🔁 Workflow Loops | [Example](examples/python/workflows/workflow_loops.py) | [📖](https://docs.praison.ai/features/workflows) |
| 📞 Task Callbacks | [Example](examples/python/workflows/task_callbacks.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🛑 Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/features/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) |
| 🔄 Conditional Steps | [Example](examples/python/workflows/workflow_conditional.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🎭 Mixed Steps | [Example](examples/python/workflows/workflow_mixed_steps.py) | [📖](https://docs.praison.ai/features/workflows) |
| 🔀 Agentic Routing | [Example](examples/python/workflows/workflow_routing.py) | [📖](https://docs.praison.ai/features/workflow-routing) |
| ⚡ Parallel Execution | [Example](examples/python/workflows/workflow_parallel.py) | [📖](https://docs.praison.ai/features/workflow-parallel) |
| 🔁 Loop over List | [Example](examples/python/workflows/workflow_loop_list.py) | [📖](https://docs.praison.ai/features/workflow-loop) |
| 📄 Loop over CSV | [Example](examples/python/workflows/workflow_loop_csv.py) | [📖](https://docs.praison.ai/features/workflow-loop) |
| 🔄 Evaluator-Optimizer | [Example](examples/python/workflows/workflow_repeat.py) | [📖](https://docs.praison.ai/features/workflow-repeat) |
## Supported Providers
| Provider | Example |
|----------|:-------:|
| OpenAI | [Example](examples/python/providers/openai/openai_gpt4_example.py) |
| Anthropic | [Example](examples/python/providers/anthropic/anthropic_claude_example.py) |
| Google Gemini | [Example](examples/python/providers/google/google_gemini_example.py) |
| Ollama | [Example](examples/python/providers/ollama/ollama-agents.py) |
| Groq | [Example](examples/python/providers/groq/kimi_with_groq_example.py) |
| DeepSeek | [Example](examples/python/providers/deepseek/deepseek_example.py) |
| xAI Grok | [Example](examples/python/providers/xai/xai_grok_example.py) |
| Mistral | [Example](examples/python/providers/mistral/mistral_example.py) |
| Cohere | [Example](examples/python/providers/cohere/cohere_example.py) |
| Perplexity | [Example](examples/python/providers/perplexity/perplexity_example.py) |
| Fireworks | [Example](examples/python/providers/fireworks/fireworks_example.py) |
| Together AI | [Example](examples/python/providers/together/together_ai_example.py) |
| OpenRouter | [Example](examples/python/providers/openrouter/openrouter_example.py) |
| HuggingFace | [Example](examples/python/providers/huggingface/huggingface_example.py) |
| Azure OpenAI | [Example](examples/python/providers/azure/azure_openai_example.py) |
| AWS Bedrock | [Example](examples/python/providers/aws/aws_bedrock_example.py) |
| Google Vertex | [Example](examples/python/providers/vertex/vertex_example.py) |
| Databricks | [Example](examples/python/providers/databricks/databricks_example.py) |
| Cloudflare | [Example](examples/python/providers/cloudflare/cloudflare_example.py) |
| AI21 | [Example](examples/python/providers/ai21/ai21_example.py) |
| Replicate | [Example](examples/python/providers/replicate/replicate_example.py) |
| SageMaker | [Example](examples/python/providers/sagemaker/sagemaker_example.py) |
| Moonshot | [Example](examples/python/providers/moonshot/moonshot_example.py) |
| vLLM | [Example](examples/python/providers/vllm/vllm_example.py) |
## Using Python Code
@@ -334,19 +412,172 @@ memories = auto.process_interaction(
### 8. Workflows
Create reusable multi-step workflows in `.praison/workflows/`:
Create reusable multi-step workflows with context passing and per-step agents:
```python
from praisonaiagents.memory import WorkflowManager
from praisonaiagents import Agent
from praisonaiagents.memory import WorkflowManager, Workflow, WorkflowStep
# Simple execution with default agent
agent = Agent(name="Assistant", llm="gpt-4o-mini")
manager = WorkflowManager()
# Execute a workflow
result = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
default_agent=agent,
variables={"environment": "production"}
)
# Advanced: Per-step agent configuration
workflow = Workflow(
name="research_pipeline",
default_llm="gpt-4o-mini",
steps=[
WorkflowStep(
name="research",
action="Research {{topic}}",
agent_config={"role": "Researcher", "goal": "Find information"},
tools=["tavily_search"]
),
WorkflowStep(
name="write",
action="Write report based on {{previous_output}}",
agent_config={"role": "Writer", "goal": "Write content"},
context_from=["research"] # Only include research output
)
]
)
# Async execution
import asyncio
result = asyncio.run(manager.aexecute("deploy", default_llm="gpt-4o-mini"))
```
**Key Features:**
- **Context Passing**: Use `{{previous_output}}` and `{{step_name_output}}` variables
- **Per-Step Agents**: Configure different agents with roles, goals, tools for each step
- **Async Execution**: Use `aexecute()` for async workflows
- **Planning Mode**: Enable at workflow level with `planning=True`
- **Branching**: Use `next_steps` and `branch_condition` for conditional routing
- **Loops**: Use `loop_over` and `loop_var` to iterate over data
### Choosing the Right Workflow System
| Use Case | Recommended |
|----------|-------------|
| Simple function pipelines | `Workflow` class ⭐ |
| Agent-only pipelines | `Workflow` class |
| CSV batch processing | `process="workflow"` |
| Complex task routing | `process="workflow"` |
| Markdown templates | `WorkflowManager` |
| Early stop / conditional | `Workflow` class |
### Simple Workflow (Recommended)
The easiest way to create workflows - just pass functions as steps:
```python
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Define simple handler functions
def validate(ctx: WorkflowContext) -> StepResult:
if not ctx.input:
return StepResult(output="No input", stop_workflow=True)
return StepResult(output=f"Valid: {ctx.input}")
def process(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"Processed: {ctx.previous_result}")
# Create and run workflow
workflow = Workflow(steps=[validate, process])
result = workflow.start("Hello World", verbose=True)
print(result["output"]) # "Processed: Valid: Hello World"
```
**Key Features:**
- **Just pass functions** - No complex configuration needed
- **Early stop** - Return `stop_workflow=True` to stop the workflow
- **Context passing** - Access `ctx.input`, `ctx.previous_result`, `ctx.variables`
- **Verbose mode** - See step-by-step progress
### Workflow Branching & Loops
```python
from praisonaiagents import WorkflowStep
# Branching step
decision_step = WorkflowStep(
name="decide",
action="Evaluate if task is complete",
next_steps=["success_step", "retry_step"],
branch_condition={"success": ["success_step"], "failure": ["retry_step"]}
)
# Loop step
loop_step = WorkflowStep(
name="process_items",
action="Process {{item}}",
loop_over="items", # Variable containing list
loop_var="item" # Current item variable name
)
```
### Workflow Patterns (route, parallel, loop, repeat)
```python
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Or use Pipeline (alias for Workflow)
from praisonaiagents import Pipeline
from praisonaiagents.workflows import route, parallel, loop, repeat
# 1. ROUTING - Decision-based branching
workflow = Workflow(steps=[
classify_request, # Returns "approve" or "reject"
route({
"approve": [approve_handler],
"reject": [reject_handler],
"default": [fallback_handler]
})
])
# 2. PARALLEL - Concurrent execution
workflow = Workflow(steps=[
parallel([research_market, research_competitors, research_customers]),
summarize_results # Gets all parallel outputs
])
# 3. LOOP - Iterate over list or CSV
workflow = Workflow(
steps=[loop(process_item, over="items")],
variables={"items": ["a", "b", "c"]}
)
# Or from CSV file:
workflow = Workflow(steps=[loop(process_row, from_csv="data.csv")])
# 4. REPEAT - Evaluator-Optimizer pattern
workflow = Workflow(steps=[
repeat(
generator,
until=lambda ctx: "done" in ctx.previous_result,
max_iterations=5
)
])
# 5. CALLBACKS - Monitor workflow execution
workflow = Workflow(
steps=[step1, step2],
on_workflow_start=lambda w, i: print(f"Starting: {i}"),
on_step_complete=lambda name, r: print(f"{name}: {r.output[:50]}"),
on_workflow_complete=lambda w, r: print(f"Done: {r['status']}")
)
# 6. GUARDRAILS - Validate and retry
def validate(result):
return ("error" not in result.output, "Fix the error")
workflow = Workflow(steps=[
WorkflowStep(name="gen", handler=generator, guardrail=validate, max_retries=3)
])
```
### 9. Hooks
@@ -483,19 +714,49 @@ praisonai "Task" --include-rules security,testing
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute a workflow with tools and save output
praisonai workflow run "Research Blog" --tools tavily --save
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Execute with planning mode (AI creates sub-steps for each workflow step)
praisonai workflow run "Research Blog" --planning --verbose
# Execute with reasoning mode (chain-of-thought)
praisonai workflow run "Analysis" --reasoning --verbose
# Execute with memory enabled
praisonai workflow run "Research" --memory
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
# Inline workflow (no template file needed)
praisonai "What is AI?" --workflow "Research,Summarize" --save
# Inline workflow with step actions
praisonai "GPT-5" --workflow "Research:Search for info,Write:Write blog" --tools tavily
# Workflow CLI help
praisonai workflow help
```
**Workflow CLI Options:**
| Flag | Description |
|------|-------------|
| `--workflow-var key=value` | Set workflow variable (can be repeated) |
| `--llm <model>` | LLM model (e.g., openai/gpt-4o-mini) |
| `--tools <tools>` | Tools (comma-separated, e.g., tavily) |
| `--planning` | Enable planning mode |
| `--reasoning` | Enable reasoning mode |
| `--memory` | Enable memory |
| `--verbose` | Enable verbose output |
| `--save` | Save output to file |
### Hooks CLI:
```bash
# List configured hooks
@@ -514,6 +775,465 @@ praisonai hooks init
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
### Guardrail CLI:
```bash
# Validate output with LLM guardrail
praisonai "Write code" --guardrail "Ensure code is secure and follows best practices"
# Combine with other flags
praisonai "Generate SQL query" --guardrail "No DROP or DELETE statements" --save
```
### Metrics CLI:
```bash
# Display token usage and cost metrics
praisonai "Analyze this data" --metrics
# Combine with other features
praisonai "Complex task" --metrics --planning
```
### Image Processing CLI:
```bash
# Process images with vision-based tasks
praisonai "Describe this image" --image path/to/image.png
# Analyze image content
praisonai "What objects are in this photo?" --image photo.jpg --llm openai/gpt-4o
```
### Telemetry CLI:
```bash
# Enable usage monitoring and analytics
praisonai "Task" --telemetry
# Combine with metrics for full observability
praisonai "Complex analysis" --telemetry --metrics
```
### MCP (Model Context Protocol) CLI:
```bash
# Use MCP server tools
praisonai "Search files" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
# MCP with environment variables
praisonai "Search web" --mcp "npx -y @modelcontextprotocol/server-brave-search" --mcp-env "BRAVE_API_KEY=your_key"
# Multiple MCP options
praisonai "Task" --mcp "npx server" --mcp-env "KEY1=value1,KEY2=value2"
```
### Fast Context CLI:
```bash
# Search codebase for relevant context
praisonai "Find authentication code" --fast-context ./src
# Add code context to any task
praisonai "Explain this function" --fast-context /path/to/project
```
### Knowledge CLI:
```bash
# Add documents to knowledge base
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
# Search knowledge base
praisonai knowledge search "API authentication"
# List indexed documents
praisonai knowledge list
# Clear knowledge base
praisonai knowledge clear
# Show knowledge base info
praisonai knowledge info
# Show all commands
praisonai knowledge help
```
### Session CLI:
```bash
# List all saved sessions
praisonai session list
# Show session details
praisonai session show my-project
# Resume a session (load into memory)
praisonai session resume my-project
# Delete a session
praisonai session delete my-project
# Auto-save session after each run
praisonai "Analyze this code" --auto-save my-project
# Load history from last N sessions into context
praisonai "Continue our discussion" --history 5
```
### Session Management (Python):
```python
from praisonaiagents import Agent
# Auto-save session after each run
agent = Agent(
name="Assistant",
memory=True,
auto_save="my-project"
)
# Load history from last 5 sessions
agent = Agent(
name="Assistant",
memory=True,
history_in_context=5
)
```
### Workflow Checkpoints:
```python
from praisonaiagents.memory.workflows import WorkflowManager
manager = WorkflowManager()
# Save checkpoint after each step
result = manager.execute("deploy", checkpoint="deploy-v1")
# Resume from checkpoint
result = manager.execute("deploy", resume="deploy-v1")
# List/delete checkpoints
manager.list_checkpoints()
manager.delete_checkpoint("deploy-v1")
```
### Tools CLI:
```bash
# List all available tools
praisonai tools list
# Get info about a specific tool
praisonai tools info internet_search
# Search for tools
praisonai tools search "web"
# Show all commands
praisonai tools help
```
### Handoff CLI:
```bash
# Enable agent-to-agent task delegation
praisonai "Research and write article" --handoff "researcher,writer,editor"
# Complex multi-agent workflow
praisonai "Analyze data and create report" --handoff "analyst,visualizer,writer"
```
### Auto Memory CLI:
```bash
# Enable automatic memory extraction
praisonai "Learn about user preferences" --auto-memory
# Combine with user isolation
praisonai "Remember my settings" --auto-memory --user-id user123
```
### Todo CLI:
```bash
# Generate todo list from task
praisonai "Plan the project" --todo
# Add a todo item
praisonai todo add "Implement feature X"
# List all todos
praisonai todo list
# Complete a todo
praisonai todo complete 1
# Delete a todo
praisonai todo delete 1
# Clear all todos
praisonai todo clear
# Show all commands
praisonai todo help
```
### Router CLI:
```bash
# Auto-select best model based on task complexity
praisonai "Simple question" --router
# Specify preferred provider
praisonai "Complex analysis" --router --router-provider anthropic
# Router automatically selects:
# - Simple tasks → gpt-4o-mini, claude-3-haiku
# - Complex tasks → gpt-4-turbo, claude-3-opus
```
### Flow Display CLI:
```bash
# Enable visual workflow tracking
praisonai agents.yaml --flow-display
# Combine with other features
praisonai "Multi-step task" --planning --flow-display
```
### Docs CLI:
```bash
# List all project docs
praisonai docs list
# Create a new doc
praisonai docs create project-overview "This project is a Python web app..."
# Show a specific doc
praisonai docs show project-overview
# Delete a doc
praisonai docs delete old-doc
# Show all commands
praisonai docs help
```
### MCP Config CLI:
```bash
# List all MCP configurations
praisonai mcp list
# Create a new MCP config
praisonai mcp create filesystem npx -y @modelcontextprotocol/server-filesystem .
# Show a specific config
praisonai mcp show filesystem
# Enable/disable a config
praisonai mcp enable filesystem
praisonai mcp disable filesystem
# Delete a config
praisonai mcp delete filesystem
# Show all commands
praisonai mcp help
```
### AI Commit CLI:
```bash
# Generate AI commit message for staged changes
praisonai commit
# Generate, commit, and push
praisonai commit --push
```
### @Mentions in Prompts:
```bash
# Include file content in prompt
praisonai "@file:src/main.py explain this code"
# Include project doc
praisonai "@doc:project-overview help me add a feature"
# Search the web
praisonai "@web:python best practices give me tips"
# Fetch URL content
praisonai "@url:https://docs.python.org summarize this"
# Combine multiple mentions
praisonai "@file:main.py @doc:coding-standards review this code"
```
## Prompt Expansion
Expand short prompts into detailed, actionable prompts:
### CLI Usage
```bash
# Expand a short prompt into detailed prompt
praisonai "write a movie script in 3 lines" --expand-prompt
# With verbose output
praisonai "blog about AI" --expand-prompt -v
# With tools for context gathering
praisonai "latest AI trends" --expand-prompt --expand-tools tools.py
# Combine with query rewrite
praisonai "AI news" --query-rewrite --expand-prompt
```
### Programmatic Usage
```python
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
# Basic usage
agent = PromptExpanderAgent()
result = agent.expand("write a movie script in 3 lines")
print(result.expanded_prompt)
# With specific strategy
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
# Available strategies: BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO
```
**Key Difference:**
- `--query-rewrite`: Optimizes queries for search/retrieval (RAG)
- `--expand-prompt`: Expands prompts for detailed task execution
## Web Search, Web Fetch & Prompt Caching
### CLI Usage
```bash
# Web Search - Get real-time information
praisonai "What are the latest AI news today?" --web-search --llm openai/gpt-4o-search-preview
# Web Fetch - Retrieve and analyze URL content (Anthropic only)
praisonai "Summarize https://docs.praison.ai" --web-fetch --llm anthropic/claude-sonnet-4-20250514
# Prompt Caching - Reduce costs for repeated prompts
praisonai "Analyze this document..." --prompt-caching --llm anthropic/claude-sonnet-4-20250514
```
### Programmatic Usage
```python
from praisonaiagents import Agent
# Web Search
agent = Agent(
instructions="You are a research assistant",
llm="openai/gpt-4o-search-preview",
web_search=True
)
# Web Fetch (Anthropic only)
agent = Agent(
instructions="You are a content analyzer",
llm="anthropic/claude-sonnet-4-20250514",
web_fetch=True
)
# Prompt Caching
agent = Agent(
instructions="You are an AI assistant..." * 50, # Long system prompt
llm="anthropic/claude-sonnet-4-20250514",
prompt_caching=True
)
```
**Supported Providers:**
| Feature | Providers |
|---------|----------|
| Web Search | OpenAI, Gemini, Anthropic, xAI, Perplexity |
| 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"}
)
)
```
### 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 |
## CLI Features
| Feature | Docs |
|---------|:----:|
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
## Using JavaScript Code
```bash
@@ -932,7 +1652,6 @@ praisonai agents.yaml
</a>
</div>
## Custom Tools
### Using `@tool` Decorator
@@ -1098,6 +1817,73 @@ agent = Agent(
| 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"}
)
)
```
### 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 |
## Development:
Below is used for development only.
@@ -1138,6 +1924,65 @@ cd src/praisonai && uv publish
- Submit a pull request via GitHub's web interface.
- Await feedback from project maintainers.
## Advanced Features
**Research & Intelligence:**
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 📝 **Prompt Expander Agent** (Expand short prompts into detailed instructions)
**Memory & Caching:**
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
**Planning & Workflows:**
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
- ⛓️ **Prompt Chaining** (Sequential prompt workflows with gates)
- 🔍 **Evaluator Optimiser** (Generate and optimize through iterative feedback)
- 👷 **Orchestrator Workers** (Distribute tasks among specialized workers)
- ⚡ **Parallelisation** (Execute tasks in parallel for improved performance)
- 🔁 **Repetitive Agents** (Handle repetitive tasks through automated loops)
- 🤖 **Autonomous Workflow** (Monitor, act, adapt based on environment feedback)
**Agent Types:**
- 🖼️ **Image Generation Agent** (Create images from text descriptions)
- 📷 **Image to Text Agent** (Extract text and descriptions from images)
- 🎬 **Video Agent** (Analyze and process video content)
- 📊 **Data Analyst Agent** (Analyze data and generate insights)
- 💰 **Finance Agent** (Financial analysis and recommendations)
- 🛒 **Shopping Agent** (Price comparison and shopping assistance)
- ⭐ **Recommendation Agent** (Personalized recommendations)
- 📖 **Wikipedia Agent** (Search and extract Wikipedia information)
- 💻 **Programming Agent** (Code development and analysis)
- 📝 **Markdown Agent** (Generate and format Markdown content)
- 🔀 **Router Agent** (Dynamic task routing with cost optimization)
**MCP Protocol:**
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
**Safety & Control:**
- 🤝 **Agent Handoffs** (Transfer context between specialized agents)
- 🛡️ **Guardrails** (Input/output validation and safety checks)
- ✅ **Human Approval** (Require human confirmation for critical actions)
- 💬 **Sessions Management** (Isolated conversation contexts)
- 🔄 **Stateful Agents** (Maintain state across interactions)
**Developer Tools:**
- ⚡ **Fast Context** (Rapid parallel code search - 10-20x faster than traditional methods)
- 📜 **Rules & Instructions** (Auto-discover CLAUDE.md, AGENTS.md, GEMINI.md)
- 🪝 **Hooks** (Pre/post operation hooks for custom logic)
- 📈 **Telemetry** (Track agent performance and usage)
- 📹 **Camera Integration** (Capture and analyze camera input)
## Other Features
- 🔄 Use CrewAI or AG2 (Formerly AutoGen) Framework
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.10.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.10.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.23.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.23.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+19
View File
@@ -0,0 +1,19 @@
"""
PraisonAI CLI Package
This package provides the command-line interface for PraisonAI.
Structure:
- main.py: Main CLI entry point (PraisonAI class)
- features/: Feature handlers for CLI flags and commands
"""
from .main import PraisonAI
__all__ = ["PraisonAI"]
def main():
"""CLI entry point function."""
praison_ai = PraisonAI()
praison_ai.main()
@@ -0,0 +1,88 @@
"""
CLI Features Module for PraisonAI
This module contains modular CLI feature handlers that extend the base CLI functionality.
Each feature is implemented as a separate module for maintainability and testability.
Features:
- guardrail: Output validation using LLM guardrails
- metrics: Token usage and cost tracking
- image: Image processing with ImageAgent
- telemetry: Usage monitoring and analytics
- mcp: Model Context Protocol server integration
- fast_context: Codebase search capability
- knowledge: RAG/vector store management
- session: Session management for multi-turn conversations
- tools: Tool registry management
- handoff: Agent-to-agent delegation
- auto_memory: Automatic memory extraction
- todo: Todo list management
- router: Smart model selection
- flow_display: Visual workflow tracking
"""
# Type hints available for IDE support
# Lazy imports to avoid performance impact
__all__ = [
'GuardrailHandler',
'MetricsHandler',
'ImageHandler',
'TelemetryHandler',
'MCPHandler',
'FastContextHandler',
'KnowledgeHandler',
'SessionHandler',
'ToolsHandler',
'HandoffHandler',
'AutoMemoryHandler',
'TodoHandler',
'RouterHandler',
'FlowDisplayHandler',
]
def __getattr__(name):
"""Lazy load handlers to minimize import overhead."""
if name == 'GuardrailHandler':
from .guardrail import GuardrailHandler
return GuardrailHandler
elif name == 'MetricsHandler':
from .metrics import MetricsHandler
return MetricsHandler
elif name == 'ImageHandler':
from .image import ImageHandler
return ImageHandler
elif name == 'TelemetryHandler':
from .telemetry import TelemetryHandler
return TelemetryHandler
elif name == 'MCPHandler':
from .mcp import MCPHandler
return MCPHandler
elif name == 'FastContextHandler':
from .fast_context import FastContextHandler
return FastContextHandler
elif name == 'KnowledgeHandler':
from .knowledge import KnowledgeHandler
return KnowledgeHandler
elif name == 'SessionHandler':
from .session import SessionHandler
return SessionHandler
elif name == 'ToolsHandler':
from .tools import ToolsHandler
return ToolsHandler
elif name == 'HandoffHandler':
from .handoff import HandoffHandler
return HandoffHandler
elif name == 'AutoMemoryHandler':
from .auto_memory import AutoMemoryHandler
return AutoMemoryHandler
elif name == 'TodoHandler':
from .todo import TodoHandler
return TodoHandler
elif name == 'RouterHandler':
from .router import RouterHandler
return RouterHandler
elif name == 'FlowDisplayHandler':
from .flow_display import FlowDisplayHandler
return FlowDisplayHandler
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,173 @@
"""
Auto Memory Handler for CLI.
Provides automatic memory extraction and storage.
Usage: praisonai "Learn about user preferences" --auto-memory
"""
from typing import Any, Dict, Tuple
from .base import FlagHandler
class AutoMemoryHandler(FlagHandler):
"""
Handler for --auto-memory flag.
Automatically extracts and stores important information from conversations.
Example:
praisonai "Learn about user preferences" --auto-memory
praisonai "Remember this context" --auto-memory --user-id myuser
"""
@property
def feature_name(self) -> str:
return "auto_memory"
@property
def flag_name(self) -> str:
return "auto-memory"
@property
def flag_help(self) -> str:
return "Enable automatic memory extraction and storage"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if AutoMemory is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
return True, ""
return False, "praisonaiagents not installed"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def _get_auto_memory(self, user_id: str = None):
"""Get AutoMemory instance lazily."""
try:
from praisonaiagents.memory import AutoMemory
return AutoMemory(user_id=user_id or "default")
except ImportError:
self.print_status(
"AutoMemory requires praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply auto memory configuration.
Args:
config: Agent configuration dictionary
flag_value: Boolean or dict with user_id
Returns:
Modified configuration
"""
if flag_value:
config['auto_memory'] = True
if isinstance(flag_value, dict):
config['auto_memory_user_id'] = flag_value.get('user_id', 'default')
return config
def extract_memories(self, text: str, user_id: str = None) -> Dict[str, Any]:
"""
Extract memories from text.
Args:
text: Text to extract memories from
user_id: User ID for memory isolation
Returns:
Dictionary of extracted memories
"""
auto_memory = self._get_auto_memory(user_id)
if not auto_memory:
return {}
try:
if hasattr(auto_memory, 'extract'):
memories = auto_memory.extract(text)
self.print_status(f"🧠 Extracted {len(memories)} memories", "success")
return memories
elif hasattr(auto_memory, 'process'):
result = auto_memory.process(text)
return result
except Exception as e:
self.log(f"Memory extraction failed: {e}", "error")
return {}
def store_memory(self, content: str, user_id: str = None, importance: float = 0.5) -> bool:
"""
Store a memory.
Args:
content: Memory content
user_id: User ID for memory isolation
importance: Importance score (0-1)
Returns:
True if successful
"""
auto_memory = self._get_auto_memory(user_id)
if not auto_memory:
return False
try:
if hasattr(auto_memory, 'store'):
auto_memory.store(content, importance=importance)
elif hasattr(auto_memory, 'add'):
auto_memory.add(content, importance=importance)
self.print_status("✅ Memory stored", "success")
return True
except Exception as e:
self.log(f"Memory storage failed: {e}", "error")
return False
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process result to extract and store memories.
Args:
result: Agent output
flag_value: Boolean or dict with configuration
Returns:
Original result (memories are stored)
"""
if not flag_value:
return result
user_id = None
if isinstance(flag_value, dict):
user_id = flag_value.get('user_id')
# Extract memories from result
text = str(result)
memories = self.extract_memories(text, user_id)
if memories:
self.print_status("\n🧠 Auto-extracted Memories:", "info")
for key, value in memories.items():
self.print_status(f" • {key}: {value[:50]}...", "info")
return result
def execute(self, text: str = None, user_id: str = None, **kwargs) -> Dict[str, Any]:
"""
Execute auto memory extraction.
Args:
text: Text to process
user_id: User ID for memory isolation
Returns:
Dictionary of extracted memories
"""
if not text:
return {}
return self.extract_memories(text, user_id)
@@ -0,0 +1,168 @@
"""
Base handler class for CLI features.
All CLI feature handlers inherit from this base class to ensure
consistent interface and behavior.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List
import logging
logger = logging.getLogger(__name__)
class BaseHandler(ABC):
"""
Base class for all CLI feature handlers.
Provides common functionality and interface for feature handlers.
Each handler is responsible for a specific CLI feature.
"""
def __init__(self, verbose: bool = False):
"""
Initialize the handler.
Args:
verbose: Enable verbose output
"""
self.verbose = verbose
self._initialized = False
@property
@abstractmethod
def feature_name(self) -> str:
"""Return the name of this feature."""
pass
@property
def is_available(self) -> bool:
"""Check if the feature dependencies are available."""
return True
def log(self, message: str, level: str = "info"):
"""Log a message if verbose mode is enabled."""
if self.verbose:
getattr(logger, level)(f"[{self.feature_name}] {message}")
def print_status(self, message: str, status: str = "info"):
"""Print a status message with color coding."""
from rich import print as rprint
colors = {
"info": "cyan",
"success": "green",
"warning": "yellow",
"error": "red"
}
color = colors.get(status, "white")
rprint(f"[{color}]{message}[/{color}]")
def check_dependencies(self) -> tuple[bool, str]:
"""
Check if required dependencies are installed.
Returns:
Tuple of (available, error_message)
"""
return True, ""
@abstractmethod
def execute(self, *args, **kwargs) -> Any:
"""
Execute the feature's main functionality.
This method must be implemented by each handler.
"""
pass
class CommandHandler(BaseHandler):
"""
Base class for CLI command handlers (e.g., 'praisonai knowledge').
Command handlers process subcommands like:
- praisonai <command> <action> [args]
"""
@abstractmethod
def get_actions(self) -> List[str]:
"""Return list of supported actions for this command."""
pass
def get_help_text(self) -> str:
"""Return help text for this command."""
return f"Available actions: {', '.join(self.get_actions())}"
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""
Execute a command action.
Args:
action: The action to perform
action_args: Arguments for the action
**kwargs: Additional keyword arguments
"""
if action == 'help' or action == '--help':
self.print_help()
return
method_name = f"action_{action.replace('-', '_')}"
if hasattr(self, method_name):
return getattr(self, method_name)(action_args, **kwargs)
else:
self.print_status(f"Unknown action: {action}", "error")
self.print_help()
def print_help(self):
"""Print help for this command."""
from rich import print as rprint
rprint(f"[bold]{self.feature_name.title()} Commands:[/bold]")
rprint(self.get_help_text())
class FlagHandler(BaseHandler):
"""
Base class for CLI flag handlers (e.g., '--guardrail').
Flag handlers modify agent behavior or add functionality
when a specific flag is provided.
"""
@property
@abstractmethod
def flag_name(self) -> str:
"""Return the flag name (without --)."""
pass
@property
def flag_help(self) -> str:
"""Return help text for this flag."""
return f"Enable {self.feature_name}"
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply this flag's configuration to an agent config.
Args:
config: The agent configuration dictionary
flag_value: The value of the flag
Returns:
Modified agent configuration
"""
return config
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process the agent result.
Args:
result: The agent's output
flag_value: The value of the flag
Returns:
Processed result
"""
return result
@@ -0,0 +1,189 @@
"""
Fast Context Handler for CLI.
Provides codebase search capability using FastContext.
Usage: praisonai "Find authentication code" --fast-context ./src
"""
import os
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
class FastContextHandler(FlagHandler):
"""
Handler for --fast-context flag.
Searches codebase for relevant context before agent execution.
Example:
praisonai "Find authentication code" --fast-context ./src
praisonai "Explain the database schema" --fast-context /path/to/project
"""
@property
def feature_name(self) -> str:
return "fast_context"
@property
def flag_name(self) -> str:
return "fast-context"
@property
def flag_help(self) -> str:
return "Path to search for relevant code context"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if FastContext is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
return True, ""
return False, "praisonaiagents not installed"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def validate_search_path(self, path: str) -> Tuple[bool, str]:
"""
Validate that the search path exists.
Args:
path: Path to search directory
Returns:
Tuple of (is_valid, error_message)
"""
if not path:
return False, "No search path provided"
if not os.path.isdir(path):
return False, f"Directory not found: {path}"
return True, ""
def search_context(self, query: str, path: str, **kwargs) -> List[Dict[str, Any]]:
"""
Search for relevant context in the codebase.
Args:
query: Search query
path: Path to search
**kwargs: Additional search options
Returns:
List of matching results
"""
available, msg = self.check_dependencies()
if not available:
self.print_status(msg, "error")
return []
valid, msg = self.validate_search_path(path)
if not valid:
self.print_status(msg, "error")
return []
try:
from praisonaiagents import FastContext
fc = FastContext(
path=path,
model=kwargs.get('model', 'gpt-4o-mini'),
max_turns=kwargs.get('max_turns', 4),
parallelism=kwargs.get('parallelism', 8),
timeout=kwargs.get('timeout', 30.0)
)
result = fc.search(query)
# Convert to list of dicts
matches = []
if hasattr(result, 'files'):
for file_match in result.files:
matches.append({
'file': file_match.path,
'lines': [(lr.start, lr.end) for lr in file_match.line_ranges],
'relevance': getattr(file_match, 'relevance', 1.0)
})
self.print_status(f"🔍 Found {len(matches)} relevant files", "success")
return matches
except Exception as e:
self.log(f"FastContext search error: {e}", "error")
return []
def format_context_for_prompt(self, matches: List[Dict[str, Any]], max_chars: int = 10000) -> str:
"""
Format search results as context for the prompt.
Args:
matches: List of search results
max_chars: Maximum characters to include
Returns:
Formatted context string
"""
if not matches:
return ""
context_parts = ["## Relevant Code Context\n"]
total_chars = 0
for match in matches:
file_path = match.get('file', '')
lines = match.get('lines', [])
if not file_path or not os.path.exists(file_path):
continue
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.readlines()
for start, end in lines:
snippet = ''.join(content[max(0, start-1):end])
if total_chars + len(snippet) > max_chars:
break
context_parts.append(f"\n### {file_path} (lines {start}-{end})\n```\n{snippet}```\n")
total_chars += len(snippet)
except Exception:
continue
return '\n'.join(context_parts)
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply fast context configuration.
Args:
config: Agent configuration dictionary
flag_value: Search path
Returns:
Modified configuration
"""
if flag_value:
config['fast_context'] = True
config['fast_context_path'] = flag_value
return config
def execute(self, query: str = None, path: str = None, **kwargs) -> str:
"""
Execute fast context search and return formatted context.
Args:
query: Search query
path: Search path
Returns:
Formatted context string
"""
if not query or not path:
return ""
matches = self.search_context(query, path, **kwargs)
return self.format_context_for_prompt(matches)
@@ -0,0 +1,189 @@
"""
Flow Display Handler for CLI.
Provides visual workflow tracking.
Usage: praisonai agents.yaml --flow-display
"""
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
class FlowDisplayHandler(FlagHandler):
"""
Handler for --flow-display flag.
Enables visual workflow tracking for multi-agent executions.
Example:
praisonai agents.yaml --flow-display
praisonai "Complex task" --flow-display
"""
@property
def feature_name(self) -> str:
return "flow_display"
@property
def flag_name(self) -> str:
return "flow-display"
@property
def flag_help(self) -> str:
return "Enable visual workflow tracking"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if FlowDisplay is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
return True, ""
return False, "praisonaiagents not installed"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def _get_flow_display(self):
"""Get FlowDisplay class lazily."""
try:
from praisonaiagents import FlowDisplay
return FlowDisplay
except ImportError:
return None
def create_flow_display(self, title: str = "Workflow") -> Any:
"""
Create a FlowDisplay instance.
Args:
title: Title for the workflow display
Returns:
FlowDisplay instance or None
"""
FlowDisplay = self._get_flow_display()
if not FlowDisplay:
self.print_status(
"FlowDisplay requires praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
try:
return FlowDisplay(title=title)
except Exception as e:
self.log(f"Failed to create FlowDisplay: {e}", "error")
return None
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply flow display configuration.
Args:
config: Agent configuration dictionary
flag_value: Boolean indicating flow display
Returns:
Modified configuration
"""
if flag_value:
config['flow_display'] = True
return config
def display_workflow_start(self, workflow_name: str, agents: List[str] = None):
"""
Display workflow start.
Args:
workflow_name: Name of the workflow
agents: List of agent names
"""
self.print_status(f"\n🚀 Starting Workflow: {workflow_name}", "info")
self.print_status("=" * 50, "info")
if agents:
self.print_status("Agents:", "info")
for i, agent in enumerate(agents, 1):
self.print_status(f" {i}. {agent}", "info")
self.print_status("=" * 50, "info")
def display_step(self, step_num: int, step_name: str, status: str = "running"):
"""
Display a workflow step.
Args:
step_num: Step number
step_name: Name of the step
status: Step status (running, completed, failed)
"""
icons = {
'running': '⏳',
'completed': '✅',
'failed': '❌',
'pending': '⬜'
}
icon = icons.get(status, '•')
self.print_status(f" {icon} Step {step_num}: {step_name}", "info")
def display_workflow_end(self, success: bool = True, duration: float = None):
"""
Display workflow end.
Args:
success: Whether workflow completed successfully
duration: Workflow duration in seconds
"""
self.print_status("=" * 50, "info")
if success:
msg = "✅ Workflow completed successfully"
else:
msg = "❌ Workflow failed"
if duration:
msg += f" ({duration:.2f}s)"
self.print_status(msg, "success" if success else "error")
def track_execution(self, func):
"""
Decorator to track function execution in flow display.
Args:
func: Function to track
Returns:
Wrapped function
"""
import functools
@functools.wraps(func)
def wrapper(*args, **kwargs):
func_name = func.__name__
self.display_step(1, func_name, "running")
try:
result = func(*args, **kwargs)
self.display_step(1, func_name, "completed")
return result
except Exception:
self.display_step(1, func_name, "failed")
raise
return wrapper
def execute(self, workflow_name: str = None, **kwargs) -> Any:
"""
Execute flow display setup.
Args:
workflow_name: Name of the workflow
Returns:
FlowDisplay instance or None
"""
if workflow_name:
self.display_workflow_start(workflow_name)
return self.create_flow_display(workflow_name or "Workflow")
@@ -0,0 +1,165 @@
"""
Guardrail Handler for CLI.
Provides output validation using LLM guardrails.
Usage: praisonai "prompt" --guardrail "Ensure output is valid JSON"
"""
from typing import Any, Dict, Tuple
from .base import FlagHandler
# Lazy import to avoid performance impact
PRAISONAI_AVAILABLE = False
LLMGuardrail = None
def _check_praisonai():
"""Check if praisonaiagents is available."""
global PRAISONAI_AVAILABLE, LLMGuardrail
try:
from praisonaiagents import LLMGuardrail as _LLMGuardrail
LLMGuardrail = _LLMGuardrail
PRAISONAI_AVAILABLE = True
except ImportError:
PRAISONAI_AVAILABLE = False
return PRAISONAI_AVAILABLE
class GuardrailHandler(FlagHandler):
"""
Handler for --guardrail flag.
Validates agent output using LLM-based guardrails.
Example:
praisonai "Write code" --guardrail "Ensure code is secure"
"""
@property
def feature_name(self) -> str:
return "guardrail"
@property
def flag_name(self) -> str:
return "guardrail"
@property
def flag_help(self) -> str:
return "Validate output with LLM guardrail (provide description string)"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if praisonaiagents is available."""
if _check_praisonai():
return True, ""
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply guardrail configuration to agent config.
Args:
config: Agent configuration dictionary
flag_value: Guardrail description string
Returns:
Modified configuration with guardrail settings
"""
if flag_value:
config["guardrail_description"] = flag_value
# Store for later use in post_process_result
self._guardrail_description = flag_value
return config
def create_guardrail(self, description: str, llm: Any = None):
"""
Create an LLMGuardrail instance.
Args:
description: Natural language description of validation criteria
llm: Optional LLM instance for validation
Returns:
LLMGuardrail instance or None if unavailable
"""
if not _check_praisonai():
self.print_status(
"Guardrail requires praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
return LLMGuardrail(description=description, llm=llm)
def validate_output(self, output: str, description: str, llm: Any = None) -> Tuple[bool, str]:
"""
Validate output against guardrail criteria.
Args:
output: The output to validate
description: Validation criteria description
llm: Optional LLM for validation
Returns:
Tuple of (is_valid, message)
"""
guardrail = self.create_guardrail(description, llm)
if not guardrail:
return True, "Guardrail unavailable, skipping validation"
try:
# Create a mock TaskOutput for the guardrail
from praisonaiagents import TaskOutput
task_output = TaskOutput(
description="CLI output",
summary="CLI output",
raw=output,
agent="CLI",
output_format="RAW"
)
success, result = guardrail(task_output)
if success:
return True, "Guardrail validation passed"
else:
return False, f"Guardrail validation failed: {result}"
except Exception as e:
self.log(f"Guardrail error: {e}", "warning")
return True, f"Guardrail error (skipped): {e}"
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process result with guardrail validation.
Args:
result: Agent output
flag_value: Guardrail description
Returns:
Original result (validation is logged)
"""
if not flag_value:
return result
is_valid, message = self.validate_output(str(result), flag_value)
if is_valid:
self.print_status(f"✅ {message}", "success")
else:
self.print_status(f"⚠️ {message}", "warning")
return result
def execute(self, output: str = None, guardrail_description: str = None, **kwargs) -> Tuple[bool, str]:
"""
Execute guardrail validation.
Args:
output: Output to validate
guardrail_description: Validation criteria
Returns:
Tuple of (is_valid, message)
"""
if not output or not guardrail_description:
return True, "No output or description provided"
return self.validate_output(output, guardrail_description)
@@ -0,0 +1,166 @@
"""
Handoff Handler for CLI.
Provides agent-to-agent delegation capability.
Usage: praisonai "Complex task" --handoff "researcher,writer,editor"
"""
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
class HandoffHandler(FlagHandler):
"""
Handler for --handoff flag.
Enables agent-to-agent delegation for complex tasks.
Example:
praisonai "Research and write article" --handoff "researcher,writer"
praisonai "Analyze and visualize data" --handoff "analyst,visualizer"
"""
@property
def feature_name(self) -> str:
return "handoff"
@property
def flag_name(self) -> str:
return "handoff"
@property
def flag_help(self) -> str:
return "Comma-separated list of agent roles for task delegation"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if Handoff is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
return True, ""
return False, "praisonaiagents not installed"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def parse_agent_names(self, names_str: str) -> List[str]:
"""
Parse comma-separated agent names.
Args:
names_str: Comma-separated string of agent names/roles
Returns:
List of agent names
"""
if not names_str:
return []
return [name.strip() for name in names_str.split(',') if name.strip()]
def create_agents_with_handoff(self, agent_names: List[str], llm: str = None) -> List[Any]:
"""
Create agents with handoff capability.
Args:
agent_names: List of agent role names
llm: Optional LLM model name
Returns:
List of Agent instances with handoff configured
"""
available, msg = self.check_dependencies()
if not available:
self.print_status(msg, "error")
return []
from praisonaiagents import Agent, Handoff
agents = []
# Create agents for each role
for i, name in enumerate(agent_names):
agent_config = {
"name": name.title().replace("_", " "),
"role": name.title().replace("_", " "),
"goal": f"Complete tasks as {name}",
"backstory": f"Expert {name} with specialized skills"
}
if llm:
agent_config["llm"] = llm
agent = Agent(**agent_config)
agents.append(agent)
# Set up handoffs between agents
for i, agent in enumerate(agents[:-1]):
next_agent = agents[i + 1]
handoff = Handoff(
target=next_agent,
name=f"handoff_to_{agent_names[i + 1]}",
description=f"Hand off task to {agent_names[i + 1]} for further processing"
)
# Add handoff to agent
if not hasattr(agent, 'handoffs') or agent.handoffs is None:
agent.handoffs = []
agent.handoffs.append(handoff)
self.print_status(f"🤝 Created {len(agents)} agents with handoff chain", "success")
for i, name in enumerate(agent_names):
arrow = " → " if i < len(agent_names) - 1 else ""
self.print_status(f" {name}{arrow}", "info")
return agents
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply handoff configuration.
Args:
config: Agent configuration dictionary
flag_value: Comma-separated agent names
Returns:
Modified configuration
"""
if flag_value:
agent_names = self.parse_agent_names(flag_value)
config['handoff_agents'] = agent_names
config['use_handoff'] = True
return config
def execute(self, prompt: str = None, agent_names: str = None, llm: str = None, **kwargs) -> Any:
"""
Execute task with handoff agents.
Args:
prompt: Task prompt
agent_names: Comma-separated agent names
llm: Optional LLM model
Returns:
Task result
"""
if not agent_names:
self.print_status("No agent names provided for handoff", "error")
return None
names = self.parse_agent_names(agent_names)
if len(names) < 2:
self.print_status("Handoff requires at least 2 agents", "error")
return None
agents = self.create_agents_with_handoff(names, llm)
if not agents:
return None
if prompt:
# Execute with first agent
try:
result = agents[0].start(prompt)
return result
except Exception as e:
self.print_status(f"Handoff execution failed: {e}", "error")
return None
return agents
@@ -0,0 +1,207 @@
"""
Image Handler for CLI.
Provides image processing capabilities using ImageAgent.
Usage: praisonai "Describe this image" --image path/to/image.png
"""
import os
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
# Supported image extensions
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
class ImageHandler(FlagHandler):
"""
Handler for --image flag.
Processes images using ImageAgent for vision-based tasks.
Example:
praisonai "Describe this image" --image photo.png
praisonai "What's in this image?" --image /path/to/image.jpg
"""
@property
def feature_name(self) -> str:
return "image"
@property
def flag_name(self) -> str:
return "image"
@property
def flag_help(self) -> str:
return "Path to image file for vision-based tasks"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if ImageAgent is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
return True, ""
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def validate_image_path(self, path: str) -> Tuple[bool, str]:
"""
Validate that the image path is valid.
Args:
path: Path to the image file
Returns:
Tuple of (is_valid, error_message)
"""
if not path:
return False, "No image path provided"
# Check extension
ext = os.path.splitext(path)[1].lower()
if ext not in SUPPORTED_EXTENSIONS:
return False, f"Unsupported image format: {ext}. Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
# Check if file exists (for local files)
if not path.startswith(('http://', 'https://')):
if not os.path.exists(path):
return False, f"Image file not found: {path}"
return True, ""
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply image configuration to agent config.
Args:
config: Agent configuration dictionary
flag_value: Image path or list of paths
Returns:
Modified configuration with image settings
"""
if flag_value:
# Store image path(s) for use with ImageAgent
if isinstance(flag_value, str):
config["images"] = [flag_value]
elif isinstance(flag_value, list):
config["images"] = flag_value
config["use_image_agent"] = True
return config
def create_image_agent(self, llm: str = None, **kwargs) -> Any:
"""
Create an ImageAgent instance.
Args:
llm: Optional LLM model name
**kwargs: Additional agent configuration
Returns:
ImageAgent instance or None if unavailable
"""
available, msg = self.check_dependencies()
if not available:
self.print_status(msg, "error")
return None
from praisonaiagents import ImageAgent
agent_config = {
"name": "ImageAnalyzer",
"role": "Image Analyst",
"goal": "Analyze and describe images accurately",
"backstory": "Expert at understanding visual content"
}
if llm:
agent_config["llm"] = llm
agent_config.update(kwargs)
return ImageAgent(**agent_config)
def process_image(self, prompt: str, image_path: str, llm: str = None) -> str:
"""
Process an image with the given prompt.
Args:
prompt: The prompt/question about the image
image_path: Path to the image file
llm: Optional LLM model name
Returns:
Agent's response about the image
"""
# Validate image path
valid, msg = self.validate_image_path(image_path)
if not valid:
return f"Error: {msg}"
agent = self.create_image_agent(llm=llm)
if not agent:
return "Error: Could not create ImageAgent"
try:
# ImageAgent expects images as a list
result = agent.chat(prompt, images=[image_path])
return result
except Exception as e:
self.log(f"Image processing error: {e}", "error")
return f"Error processing image: {e}"
def process_multiple_images(self, prompt: str, image_paths: List[str], llm: str = None) -> str:
"""
Process multiple images with the given prompt.
Args:
prompt: The prompt/question about the images
image_paths: List of paths to image files
llm: Optional LLM model name
Returns:
Agent's response about the images
"""
# Validate all image paths
for path in image_paths:
valid, msg = self.validate_image_path(path)
if not valid:
return f"Error: {msg}"
agent = self.create_image_agent(llm=llm)
if not agent:
return "Error: Could not create ImageAgent"
try:
result = agent.chat(prompt, images=image_paths)
return result
except Exception as e:
self.log(f"Image processing error: {e}", "error")
return f"Error processing images: {e}"
def execute(self, prompt: str = None, image_path: str = None, llm: str = None, **kwargs) -> str:
"""
Execute image processing.
Args:
prompt: The prompt/question about the image
image_path: Path to the image file (or comma-separated paths)
llm: Optional LLM model name
Returns:
Agent's response
"""
if not prompt:
return "Error: No prompt provided"
if not image_path:
return "Error: No image path provided"
# Handle multiple images (comma-separated)
if ',' in image_path:
paths = [p.strip() for p in image_path.split(',')]
return self.process_multiple_images(prompt, paths, llm)
return self.process_image(prompt, image_path, llm)
@@ -0,0 +1,236 @@
"""
Knowledge Handler for CLI.
Provides RAG/vector store management.
Usage: praisonai knowledge add document.pdf
praisonai knowledge search "query"
"""
import os
from typing import Any, Dict, List
from .base import CommandHandler
class KnowledgeHandler(CommandHandler):
"""
Handler for knowledge command.
Manages knowledge base for RAG (Retrieval Augmented Generation).
Example:
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge search "How to authenticate?"
praisonai knowledge list
praisonai knowledge clear
"""
def __init__(self, verbose: bool = False, workspace: str = None):
super().__init__(verbose)
self.workspace = workspace or os.getcwd()
self._knowledge = None
@property
def feature_name(self) -> str:
return "knowledge"
def get_actions(self) -> List[str]:
return ["add", "search", "list", "clear", "info", "help"]
def get_help_text(self) -> str:
return """
Knowledge Commands:
praisonai knowledge add <file|dir> - Add document(s) to knowledge base
praisonai knowledge search <query> - Search knowledge base
praisonai knowledge list - List indexed documents
praisonai knowledge clear - Clear knowledge base
praisonai knowledge info - Show knowledge base info
Options:
--workspace <path> - Workspace directory (default: current)
"""
def _get_knowledge(self):
"""Lazy load Knowledge instance."""
if self._knowledge is None:
try:
from praisonaiagents import Knowledge
self._knowledge = Knowledge()
except ImportError:
self.print_status(
"Knowledge requires praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
return self._knowledge
def action_add(self, args: List[str], **kwargs) -> bool:
"""
Add document(s) to knowledge base.
Args:
args: List containing file/directory paths
Returns:
True if successful
"""
if not args:
self.print_status("Usage: praisonai knowledge add <file|directory>", "error")
return False
knowledge = self._get_knowledge()
if not knowledge:
return False
path = args[0]
# Expand path
if not os.path.isabs(path):
path = os.path.join(self.workspace, path)
if not os.path.exists(path):
self.print_status(f"Path not found: {path}", "error")
return False
try:
if os.path.isdir(path):
# Add all files in directory
count = 0
for root, _, files in os.walk(path):
for file in files:
file_path = os.path.join(root, file)
knowledge.add(file_path)
count += 1
self.print_status(f"✅ Added {count} files from {path}", "success")
else:
knowledge.add(path)
self.print_status(f"✅ Added: {path}", "success")
return True
except Exception as e:
self.print_status(f"Failed to add: {e}", "error")
return False
def action_search(self, args: List[str], **kwargs) -> List[Dict[str, Any]]:
"""
Search knowledge base.
Args:
args: List containing search query
Returns:
List of search results
"""
if not args:
self.print_status("Usage: praisonai knowledge search <query>", "error")
return []
knowledge = self._get_knowledge()
if not knowledge:
return []
query = ' '.join(args)
limit = kwargs.get('limit', 5)
try:
results = knowledge.search(query, k=limit)
if results:
self.print_status(f"\n🔍 Search results for '{query}':\n", "info")
for i, result in enumerate(results, 1):
content = str(result)[:200]
self.print_status(f" {i}. {content}...", "info")
else:
self.print_status("No results found", "warning")
return results
except Exception as e:
self.print_status(f"Search failed: {e}", "error")
return []
def action_list(self, args: List[str], **kwargs) -> List[str]:
"""
List indexed documents.
Returns:
List of document names
"""
knowledge = self._get_knowledge()
if not knowledge:
return []
try:
if hasattr(knowledge, 'list_documents'):
docs = knowledge.list_documents()
elif hasattr(knowledge, 'documents'):
docs = list(knowledge.documents.keys()) if knowledge.documents else []
else:
docs = []
if docs:
self.print_status("\n📚 Indexed Documents:", "info")
for doc in docs:
self.print_status(f" - {doc}", "info")
else:
self.print_status("No documents indexed", "warning")
return docs
except Exception as e:
self.print_status(f"Failed to list documents: {e}", "error")
return []
def action_clear(self, args: List[str], **kwargs) -> bool:
"""
Clear knowledge base.
Returns:
True if successful
"""
knowledge = self._get_knowledge()
if not knowledge:
return False
try:
if hasattr(knowledge, 'clear'):
knowledge.clear()
elif hasattr(knowledge, 'reset'):
knowledge.reset()
self.print_status("✅ Knowledge base cleared", "success")
return True
except Exception as e:
self.print_status(f"Failed to clear: {e}", "error")
return False
def action_info(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Show knowledge base info.
Returns:
Dictionary of info
"""
knowledge = self._get_knowledge()
if not knowledge:
return {}
info = {
"workspace": self.workspace,
"type": type(knowledge).__name__
}
try:
if hasattr(knowledge, 'get_stats'):
info.update(knowledge.get_stats())
if hasattr(knowledge, 'count'):
info['document_count'] = knowledge.count()
except Exception:
pass
self.print_status("\n📊 Knowledge Base Info:", "info")
for key, value in info.items():
self.print_status(f" {key}: {value}", "info")
return info
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute knowledge command action."""
return super().execute(action, action_args, **kwargs)
+169
View File
@@ -0,0 +1,169 @@
"""
MCP Handler for CLI.
Provides Model Context Protocol server integration.
Usage: praisonai "prompt" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
"""
import shlex
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
class MCPHandler(FlagHandler):
"""
Handler for --mcp flag.
Integrates MCP (Model Context Protocol) servers as tools for agents.
Example:
praisonai "List files" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
praisonai "Search web" --mcp "npx -y @modelcontextprotocol/server-brave-search" --mcp-env "BRAVE_API_KEY=xxx"
"""
@property
def feature_name(self) -> str:
return "mcp"
@property
def flag_name(self) -> str:
return "mcp"
@property
def flag_help(self) -> str:
return "MCP server command (e.g., 'npx -y @modelcontextprotocol/server-filesystem .')"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if MCP is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents") is not None:
# Check if MCP module is available
from praisonaiagents import MCP
if MCP is not None:
return True, ""
return False, "MCP requires praisonaiagents with MCP support"
except ImportError:
return False, "praisonaiagents not installed. Install with: pip install praisonaiagents"
def parse_mcp_command(self, command: str, env_vars: str = None) -> Tuple[str, List[str], Dict[str, str]]:
"""
Parse MCP command string into command, args, and environment.
Args:
command: Full MCP command string
env_vars: Optional comma-separated environment variables (KEY=value,KEY2=value2)
Returns:
Tuple of (command, args, env_dict)
"""
# Parse command using shell-like splitting
parts = shlex.split(command)
if not parts:
return "", [], {}
cmd = parts[0]
args = parts[1:] if len(parts) > 1 else []
# Parse environment variables
env = {}
if env_vars:
for pair in env_vars.split(','):
if '=' in pair:
key, value = pair.split('=', 1)
env[key.strip()] = value.strip()
return cmd, args, env
def create_mcp_tools(self, command: str, env_vars: str = None, timeout: int = 30) -> Any:
"""
Create MCP tools from command.
Args:
command: MCP server command
env_vars: Optional environment variables
timeout: Connection timeout in seconds
Returns:
MCP instance or None if unavailable
"""
available, msg = self.check_dependencies()
if not available:
self.print_status(msg, "error")
return None
from praisonaiagents import MCP
cmd, args, env = self.parse_mcp_command(command, env_vars)
if not cmd:
self.print_status("Invalid MCP command", "error")
return None
try:
mcp = MCP(
command=cmd,
args=args,
env=env if env else None,
timeout=timeout
)
self.print_status(f"🔌 MCP server connected: {cmd}", "success")
return mcp
except Exception as e:
self.print_status(f"Failed to connect to MCP server: {e}", "error")
return None
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply MCP configuration to agent config.
Args:
config: Agent configuration dictionary
flag_value: MCP command string or dict with command and env
Returns:
Modified configuration with MCP tools
"""
if not flag_value:
return config
# Handle both string and dict formats
if isinstance(flag_value, str):
mcp = self.create_mcp_tools(flag_value)
elif isinstance(flag_value, dict):
mcp = self.create_mcp_tools(
flag_value.get('command', ''),
flag_value.get('env', ''),
flag_value.get('timeout', 30)
)
else:
return config
if mcp:
# Add MCP tools to existing tools
existing_tools = config.get('tools', [])
if isinstance(existing_tools, list):
existing_tools.extend(list(mcp))
else:
existing_tools = list(mcp)
config['tools'] = existing_tools
config['_mcp_instance'] = mcp # Store for cleanup
return config
def execute(self, command: str = None, env_vars: str = None, **kwargs) -> Any:
"""
Execute MCP setup.
Args:
command: MCP server command
env_vars: Environment variables
Returns:
MCP instance or None
"""
if not command:
self.print_status("No MCP command provided", "error")
return None
return self.create_mcp_tools(command, env_vars)
@@ -0,0 +1,167 @@
"""
Metrics Handler for CLI.
Provides token usage and cost tracking for agent executions.
Usage: praisonai "prompt" --metrics
"""
from typing import Any, Dict, Tuple
from .base import FlagHandler
class MetricsHandler(FlagHandler):
"""
Handler for --metrics flag.
Tracks and displays token usage and cost metrics after agent execution.
Example:
praisonai "Analyze data" --metrics
"""
@property
def feature_name(self) -> str:
return "metrics"
@property
def flag_name(self) -> str:
return "metrics"
@property
def flag_help(self) -> str:
return "Display token usage and cost metrics after execution"
def check_dependencies(self) -> Tuple[bool, str]:
"""Metrics are built into praisonaiagents, always available."""
return True, ""
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply metrics configuration to agent config.
Args:
config: Agent configuration dictionary
flag_value: Boolean indicating if metrics should be enabled
Returns:
Modified configuration with metrics enabled
"""
if flag_value:
config["metrics"] = True
return config
def format_metrics(self, metrics: Dict[str, Any]) -> str:
"""
Format metrics dictionary into human-readable string.
Args:
metrics: Dictionary containing token and cost metrics
Returns:
Formatted string for display
"""
lines = []
lines.append("\n📊 Execution Metrics:")
lines.append("-" * 40)
# Token metrics
if "prompt_tokens" in metrics:
lines.append(f" Prompt tokens: {metrics['prompt_tokens']:,}")
if "completion_tokens" in metrics:
lines.append(f" Completion tokens: {metrics['completion_tokens']:,}")
if "total_tokens" in metrics:
lines.append(f" Total tokens: {metrics['total_tokens']:,}")
# Cost metrics
if "cost" in metrics:
cost = metrics["cost"]
if isinstance(cost, (int, float)):
lines.append(f" Estimated cost: ${cost:.6f}")
# Timing metrics
if "duration" in metrics:
lines.append(f" Duration: {metrics['duration']:.2f}s")
if "tokens_per_second" in metrics:
lines.append(f" Tokens/second: {metrics['tokens_per_second']:.1f}")
# Model info
if "model" in metrics:
lines.append(f" Model: {metrics['model']}")
lines.append("-" * 40)
return "\n".join(lines)
def extract_metrics_from_agent(self, agent: Any) -> Dict[str, Any]:
"""
Extract metrics from an agent instance.
Args:
agent: Agent instance that may have metrics
Returns:
Dictionary of metrics
"""
metrics = {}
# Try to get metrics from various possible locations
if hasattr(agent, 'last_token_metrics'):
metrics.update(agent.last_token_metrics or {})
if hasattr(agent, 'llm') and agent.llm:
llm = agent.llm
if hasattr(llm, 'last_token_metrics'):
metrics.update(llm.last_token_metrics or {})
if hasattr(llm, 'model'):
metrics['model'] = llm.model
if hasattr(agent, 'metrics') and isinstance(agent.metrics, dict):
metrics.update(agent.metrics or {})
return metrics
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process result to display metrics.
Args:
result: Agent output (may contain metrics)
flag_value: Boolean indicating if metrics should be displayed
Returns:
Original result (metrics are printed)
"""
if not flag_value:
return result
# Try to extract metrics from result if it's a TaskOutput
metrics = {}
if hasattr(result, 'token_metrics'):
metrics = result.token_metrics or {}
elif isinstance(result, dict) and 'metrics' in result:
metrics = result['metrics']
if metrics:
formatted = self.format_metrics(metrics)
self.print_status(formatted, "info")
else:
self.print_status("📊 No metrics available for this execution", "warning")
return result
def execute(self, agent: Any = None, **kwargs) -> Dict[str, Any]:
"""
Execute metrics extraction and display.
Args:
agent: Agent instance to extract metrics from
Returns:
Dictionary of metrics
"""
if agent:
metrics = self.extract_metrics_from_agent(agent)
if metrics:
formatted = self.format_metrics(metrics)
self.print_status(formatted, "info")
return metrics
return {}
@@ -0,0 +1,160 @@
"""
Router Handler for CLI.
Provides smart model selection based on task complexity.
Usage: praisonai "Complex task" --router
"""
from typing import Any, Dict, Tuple
from .base import FlagHandler
class RouterHandler(FlagHandler):
"""
Handler for --router flag.
Automatically selects the best model based on task complexity.
Example:
praisonai "Simple question" --router
praisonai "Complex analysis" --router
"""
# Model tiers for routing
MODEL_TIERS = {
'simple': ['gpt-4o-mini', 'claude-3-haiku', 'gemini-1.5-flash'],
'medium': ['gpt-4o', 'claude-3-sonnet', 'gemini-1.5-pro'],
'complex': ['gpt-4-turbo', 'claude-3-opus', 'o1-preview'],
}
@property
def feature_name(self) -> str:
return "router"
@property
def flag_name(self) -> str:
return "router"
@property
def flag_help(self) -> str:
return "Auto-select best model based on task complexity"
def check_dependencies(self) -> Tuple[bool, str]:
"""Router is built-in, always available."""
return True, ""
def analyze_complexity(self, prompt: str) -> str:
"""
Analyze prompt complexity.
Args:
prompt: The task prompt
Returns:
Complexity level: 'simple', 'medium', or 'complex'
"""
# Simple heuristics for complexity
prompt_lower = prompt.lower()
word_count = len(prompt.split())
# Complex indicators
complex_keywords = [
'analyze', 'research', 'comprehensive', 'detailed',
'compare', 'evaluate', 'synthesize', 'multi-step',
'code review', 'architecture', 'design pattern',
'optimize', 'debug', 'refactor'
]
# Simple indicators
simple_keywords = [
'what is', 'define', 'list', 'name', 'when',
'where', 'who', 'simple', 'quick', 'brief'
]
complex_count = sum(1 for kw in complex_keywords if kw in prompt_lower)
simple_count = sum(1 for kw in simple_keywords if kw in prompt_lower)
# Determine complexity
if complex_count >= 2 or word_count > 100:
return 'complex'
elif simple_count >= 2 or word_count < 20:
return 'simple'
else:
return 'medium'
def select_model(self, prompt: str, preferred_provider: str = None) -> str:
"""
Select the best model for the prompt.
Args:
prompt: The task prompt
preferred_provider: Optional preferred provider (openai, anthropic, google)
Returns:
Selected model name
"""
complexity = self.analyze_complexity(prompt)
models = self.MODEL_TIERS.get(complexity, self.MODEL_TIERS['medium'])
# Filter by provider if specified
if preferred_provider:
provider_prefixes = {
'openai': ['gpt-', 'o1-'],
'anthropic': ['claude-'],
'google': ['gemini-']
}
prefixes = provider_prefixes.get(preferred_provider.lower(), [])
filtered = [m for m in models if any(m.startswith(p) for p in prefixes)]
if filtered:
models = filtered
selected = models[0] if models else 'gpt-4o-mini'
self.print_status(f"🎯 Task complexity: {complexity}", "info")
self.print_status(f"🤖 Selected model: {selected}", "success")
return selected
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply router configuration.
Args:
config: Agent configuration dictionary
flag_value: Boolean or dict with provider preference
Returns:
Modified configuration with selected model
"""
if not flag_value:
return config
# Get prompt from config if available
prompt = config.get('prompt', config.get('goal', ''))
preferred_provider = None
if isinstance(flag_value, dict):
preferred_provider = flag_value.get('provider')
if prompt:
selected_model = self.select_model(prompt, preferred_provider)
config['llm'] = selected_model
config['use_router'] = True
return config
def execute(self, prompt: str = None, provider: str = None, **kwargs) -> str:
"""
Execute model selection.
Args:
prompt: Task prompt
provider: Preferred provider
Returns:
Selected model name
"""
if not prompt:
return 'gpt-4o-mini' # Default
return self.select_model(prompt, provider)
@@ -0,0 +1,222 @@
"""
Session Handler for CLI.
Provides session management for multi-turn conversations.
Usage: praisonai session list
praisonai session show <name>
praisonai session resume <name>
praisonai session delete <name>
"""
from typing import Any, Dict, List
from .base import CommandHandler
class SessionHandler(CommandHandler):
"""
Handler for session command.
Manages conversation sessions using FileMemory.
Example:
praisonai session list
praisonai session show my-project
praisonai session resume my-project
praisonai session delete my-project
"""
def __init__(self, verbose: bool = False, user_id: str = "praison"):
super().__init__(verbose)
self.user_id = user_id
self._memory = None
@property
def feature_name(self) -> str:
return "session"
def get_actions(self) -> List[str]:
return ["list", "show", "resume", "delete", "help"]
def get_help_text(self) -> str:
return """
Session Commands:
praisonai session list - List all saved sessions
praisonai session show <name> - Show session details
praisonai session resume <name> - Resume a session (load into memory)
praisonai session delete <name> - Delete a session
Sessions store conversation history for multi-turn interactions.
Use --auto-save <name> flag when running agents to auto-save sessions.
"""
def _get_memory(self):
"""Get FileMemory instance lazily."""
if self._memory is None:
try:
from praisonaiagents.memory.file_memory import FileMemory
self._memory = FileMemory(user_id=self.user_id)
except ImportError:
self.print_status(
"Session requires praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
return self._memory
def action_list(self, args: List[str], **kwargs) -> List[Dict[str, Any]]:
"""
List all saved sessions.
Returns:
List of session info dicts
"""
memory = self._get_memory()
if not memory:
return []
try:
sessions = memory.list_sessions()
if sessions:
from rich.table import Table
from rich.console import Console
console = Console()
table = Table(title="Saved Sessions")
table.add_column("Name", style="cyan")
table.add_column("Saved At", style="green")
table.add_column("Messages", style="yellow")
table.add_column("Has History", style="magenta")
for s in sessions:
table.add_row(
s.get("name", ""),
s.get("saved_at", "")[:19] if s.get("saved_at") else "",
str(s.get("short_term_count", 0)),
"✅" if s.get("has_conversation") else "❌"
)
console.print(table)
else:
self.print_status("No sessions found. Use --auto-save <name> when running agents.", "warning")
return sessions
except Exception as e:
self.print_status(f"Failed to list sessions: {e}", "error")
return []
def action_show(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Show session details.
Args:
args: List containing session name
Returns:
Session data dict
"""
if not args:
self.print_status("Usage: praisonai session show <name>", "error")
return {}
memory = self._get_memory()
if not memory:
return {}
name = args[0]
try:
session_data = memory.resume_session(name)
print(f"\n📊 Session: {name}")
print(f"Saved at: {session_data.get('saved_at_iso', 'Unknown')}")
print(f"User ID: {session_data.get('user_id', 'Unknown')}")
print(f"Short-term items: {len(session_data.get('short_term', []))}")
print(f"Conversation messages: {len(session_data.get('conversation_history', []))}")
# Show last few messages
history = session_data.get('conversation_history', [])
if history:
print(f"\nLast {min(5, len(history))} messages:")
for msg in history[-5:]:
role = msg.get('role', 'unknown')
content = msg.get('content', '')[:100]
if len(msg.get('content', '')) > 100:
content += "..."
print(f" [{role}]: {content}")
return session_data
except FileNotFoundError:
self.print_status(f"Session '{name}' not found", "error")
return {}
except Exception as e:
self.print_status(f"Failed to show session: {e}", "error")
return {}
def action_resume(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Resume a session (load into memory).
Args:
args: List containing session name
Returns:
Session data dict
"""
if not args:
self.print_status("Usage: praisonai session resume <name>", "error")
return {}
memory = self._get_memory()
if not memory:
return {}
name = args[0]
try:
session_data = memory.resume_session(name)
self.print_status(f"✅ Session '{name}' resumed", "success")
print(f" Loaded {len(session_data.get('short_term', []))} memory items")
print(f" Loaded {len(session_data.get('conversation_history', []))} conversation messages")
return session_data
except FileNotFoundError:
self.print_status(f"Session '{name}' not found", "error")
return {}
except Exception as e:
self.print_status(f"Failed to resume session: {e}", "error")
return {}
def action_delete(self, args: List[str], **kwargs) -> bool:
"""
Delete a session.
Args:
args: List containing session name
Returns:
True if successful
"""
if not args:
self.print_status("Usage: praisonai session delete <name>", "error")
return False
memory = self._get_memory()
if not memory:
return False
name = args[0]
try:
if memory.delete_session(name):
self.print_status(f"✅ Session '{name}' deleted", "success")
return True
else:
self.print_status(f"Session '{name}' not found", "error")
return False
except Exception as e:
self.print_status(f"Failed to delete session: {e}", "error")
return False
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute session command action."""
return super().execute(action, action_args, **kwargs)
@@ -0,0 +1,179 @@
"""
Telemetry Handler for CLI.
Provides usage monitoring and analytics.
Usage: praisonai "prompt" --telemetry
"""
from typing import Any, Dict, Tuple
from .base import FlagHandler
# Lazy imports
enable_telemetry = None
disable_telemetry = None
get_telemetry = None
def _load_telemetry():
"""Load telemetry functions lazily."""
global enable_telemetry, disable_telemetry, get_telemetry
try:
from praisonaiagents import (
enable_telemetry as _enable,
disable_telemetry as _disable,
get_telemetry as _get
)
enable_telemetry = _enable
disable_telemetry = _disable
get_telemetry = _get
return True
except ImportError:
return False
class TelemetryHandler(FlagHandler):
"""
Handler for --telemetry flag.
Enables usage monitoring and analytics for agent executions.
Example:
praisonai "Task" --telemetry
"""
@property
def feature_name(self) -> str:
return "telemetry"
@property
def flag_name(self) -> str:
return "telemetry"
@property
def flag_help(self) -> str:
return "Enable usage monitoring and analytics"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if telemetry is available."""
if _load_telemetry():
return True, ""
return False, "Telemetry requires praisonaiagents[telemetry]. Install with: pip install praisonaiagents[telemetry]"
def enable(self, **kwargs) -> bool:
"""
Enable telemetry.
Args:
**kwargs: Additional telemetry configuration
Returns:
True if enabled successfully
"""
if not _load_telemetry():
self.print_status(
"Telemetry not available. Install with: pip install praisonaiagents[telemetry]",
"warning"
)
return False
try:
enable_telemetry(**kwargs)
self.print_status("📊 Telemetry enabled", "success")
return True
except Exception as e:
self.log(f"Failed to enable telemetry: {e}", "error")
return False
def disable(self) -> bool:
"""
Disable telemetry.
Returns:
True if disabled successfully
"""
if not _load_telemetry():
return True # Already disabled
try:
disable_telemetry()
self.print_status("📊 Telemetry disabled", "info")
return True
except Exception as e:
self.log(f"Failed to disable telemetry: {e}", "error")
return False
def get_stats(self) -> Dict[str, Any]:
"""
Get telemetry statistics.
Returns:
Dictionary of telemetry stats
"""
if not _load_telemetry():
return {}
try:
telemetry = get_telemetry()
if telemetry and hasattr(telemetry, 'get_stats'):
return telemetry.get_stats()
except Exception as e:
self.log(f"Failed to get telemetry stats: {e}", "warning")
return {}
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply telemetry configuration.
Args:
config: Agent configuration dictionary
flag_value: Boolean indicating if telemetry should be enabled
Returns:
Modified configuration
"""
if flag_value:
self.enable()
return config
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process result to show telemetry summary.
Args:
result: Agent output
flag_value: Boolean indicating if telemetry is enabled
Returns:
Original result (telemetry summary is printed)
"""
if not flag_value:
return result
stats = self.get_stats()
if stats:
self.print_status("\n📊 Telemetry Summary:", "info")
for key, value in stats.items():
self.print_status(f" {key}: {value}", "info")
return result
def execute(self, action: str = "enable", **kwargs) -> Any:
"""
Execute telemetry action.
Args:
action: Action to perform (enable, disable, stats)
Returns:
Result of the action
"""
if action == "enable":
return self.enable(**kwargs)
elif action == "disable":
return self.disable()
elif action == "stats":
return self.get_stats()
else:
self.print_status(f"Unknown telemetry action: {action}", "error")
return None
@@ -0,0 +1,334 @@
"""
Todo Handler for CLI.
Provides todo list management.
Usage: praisonai todo list
praisonai todo add "New task"
praisonai "Plan project" --todo
"""
import os
import json
from typing import Any, Dict, List
from .base import CommandHandler, FlagHandler
class TodoHandler(CommandHandler, FlagHandler):
"""
Handler for todo command and --todo flag.
Manages todo lists for task planning.
Example:
praisonai todo list
praisonai todo add "Implement feature X"
praisonai todo complete 1
praisonai "Plan project" --todo
"""
def __init__(self, verbose: bool = False, workspace: str = None):
CommandHandler.__init__(self, verbose)
self.workspace = workspace or os.path.join(os.path.expanduser("~"), ".praison")
self.todo_file = os.path.join(self.workspace, "todos.json")
os.makedirs(self.workspace, exist_ok=True)
self._todos = None
@property
def feature_name(self) -> str:
return "todo"
@property
def flag_name(self) -> str:
return "todo"
@property
def flag_help(self) -> str:
return "Generate todo list from task"
def get_actions(self) -> List[str]:
return ["list", "add", "complete", "delete", "clear", "help"]
def get_help_text(self) -> str:
return """
Todo Commands:
praisonai todo list - List all todos
praisonai todo add <task> - Add a new todo
praisonai todo complete <id> - Mark todo as complete
praisonai todo delete <id> - Delete a todo
praisonai todo clear - Clear all todos
Flag Usage:
praisonai "Plan project" --todo - Generate todos from task
"""
def _load_todos(self) -> List[Dict[str, Any]]:
"""Load todos from file."""
if self._todos is not None:
return self._todos
if os.path.exists(self.todo_file):
try:
with open(self.todo_file, 'r') as f:
self._todos = json.load(f)
except Exception:
self._todos = []
else:
self._todos = []
return self._todos
def _save_todos(self):
"""Save todos to file."""
try:
with open(self.todo_file, 'w') as f:
json.dump(self._todos or [], f, indent=2)
except Exception as e:
self.log(f"Failed to save todos: {e}", "error")
def action_list(self, args: List[str], **kwargs) -> List[Dict[str, Any]]:
"""
List all todos.
Returns:
List of todo items
"""
todos = self._load_todos()
if not todos:
self.print_status("📋 No todos found", "info")
return []
self.print_status("\n📋 Todo List:", "info")
self.print_status("-" * 50, "info")
for i, todo in enumerate(todos, 1):
status = "✅" if todo.get('completed') else "⬜"
task = todo.get('task', 'Unknown')
priority = todo.get('priority', 'medium')
priority_color = {
'high': '🔴',
'medium': '🟡',
'low': '🟢'
}.get(priority, '⚪')
self.print_status(f" {i}. {status} {priority_color} {task}", "info")
self.print_status("-" * 50, "info")
completed = sum(1 for t in todos if t.get('completed'))
self.print_status(f" {completed}/{len(todos)} completed", "info")
return todos
def action_add(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Add a new todo.
Args:
args: List containing task description
Returns:
Created todo item
"""
if not args:
self.print_status("Usage: praisonai todo add <task>", "error")
return {}
task = ' '.join(args)
priority = kwargs.get('priority', 'medium')
todos = self._load_todos()
todo = {
'id': len(todos) + 1,
'task': task,
'completed': False,
'priority': priority,
'created_at': self._get_timestamp()
}
todos.append(todo)
self._todos = todos
self._save_todos()
self.print_status(f"✅ Added: {task}", "success")
return todo
def action_complete(self, args: List[str], **kwargs) -> bool:
"""
Mark a todo as complete.
Args:
args: List containing todo ID
Returns:
True if successful
"""
if not args:
self.print_status("Usage: praisonai todo complete <id>", "error")
return False
try:
todo_id = int(args[0])
except ValueError:
self.print_status("Invalid todo ID", "error")
return False
todos = self._load_todos()
if todo_id < 1 or todo_id > len(todos):
self.print_status(f"Todo {todo_id} not found", "error")
return False
todos[todo_id - 1]['completed'] = True
todos[todo_id - 1]['completed_at'] = self._get_timestamp()
self._todos = todos
self._save_todos()
self.print_status(f"✅ Completed: {todos[todo_id - 1]['task']}", "success")
return True
def action_delete(self, args: List[str], **kwargs) -> bool:
"""
Delete a todo.
Args:
args: List containing todo ID
Returns:
True if successful
"""
if not args:
self.print_status("Usage: praisonai todo delete <id>", "error")
return False
try:
todo_id = int(args[0])
except ValueError:
self.print_status("Invalid todo ID", "error")
return False
todos = self._load_todos()
if todo_id < 1 or todo_id > len(todos):
self.print_status(f"Todo {todo_id} not found", "error")
return False
deleted = todos.pop(todo_id - 1)
self._todos = todos
self._save_todos()
self.print_status(f"🗑️ Deleted: {deleted['task']}", "success")
return True
def action_clear(self, args: List[str], **kwargs) -> bool:
"""
Clear all todos.
Returns:
True if successful
"""
self._todos = []
self._save_todos()
self.print_status("🗑️ All todos cleared", "success")
return True
def _get_timestamp(self) -> str:
"""Get current timestamp."""
from datetime import datetime
return datetime.now().isoformat()
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply todo configuration.
Args:
config: Agent configuration dictionary
flag_value: Boolean indicating todo generation
Returns:
Modified configuration
"""
if flag_value:
config['generate_todos'] = True
return config
def generate_todos_from_response(self, response: str) -> List[Dict[str, Any]]:
"""
Extract todos from agent response.
Args:
response: Agent response text
Returns:
List of extracted todos
"""
# Simple extraction - look for numbered lists or bullet points
import re
todos = []
lines = response.split('\n')
for line in lines:
line = line.strip()
# Match numbered lists (1. Task, 1) Task)
match = re.match(r'^[\d]+[\.\)]\s*(.+)$', line)
if match:
task = match.group(1).strip()
if task and len(task) > 3:
todos.append({
'task': task,
'completed': False,
'priority': 'medium'
})
# Match bullet points (- Task, * Task, • Task)
elif line.startswith(('-', '*', '•', '→')):
task = line[1:].strip()
if task and len(task) > 3:
todos.append({
'task': task,
'completed': False,
'priority': 'medium'
})
return todos
def post_process_result(self, result: Any, flag_value: Any) -> Any:
"""
Post-process result to extract todos.
Args:
result: Agent output
flag_value: Boolean indicating todo generation
Returns:
Original result (todos are saved)
"""
if not flag_value:
return result
extracted = self.generate_todos_from_response(str(result))
if extracted:
todos = self._load_todos()
for todo in extracted:
todo['id'] = len(todos) + 1
todo['created_at'] = self._get_timestamp()
todos.append(todo)
self._todos = todos
self._save_todos()
self.print_status(f"\n📋 Generated {len(extracted)} todos:", "success")
for todo in extracted:
self.print_status(f" • {todo['task']}", "info")
return result
def execute(self, action: str = None, action_args: List[str] = None, **kwargs) -> Any:
"""Execute todo command action."""
if action is None:
action = "list"
if action_args is None:
action_args = []
return CommandHandler.execute(self, action, action_args, **kwargs)
@@ -0,0 +1,218 @@
"""
Tools Handler for CLI.
Provides tool registry management.
Usage: praisonai tools list
praisonai tools info internet_search
"""
from typing import Any, Dict, List
from .base import CommandHandler
class ToolsHandler(CommandHandler):
"""
Handler for tools command.
Manages tool registry and provides tool information.
Example:
praisonai tools list
praisonai tools info internet_search
praisonai tools search "web"
"""
def __init__(self, verbose: bool = False):
super().__init__(verbose)
self._registry = None
@property
def feature_name(self) -> str:
return "tools"
def get_actions(self) -> List[str]:
return ["list", "info", "search", "help"]
def get_help_text(self) -> str:
return """
Tools Commands:
praisonai tools list - List all available tools
praisonai tools info <name> - Show tool details
praisonai tools search <query> - Search tools by name/description
Built-in tools include: internet_search, calculator, file operations, etc.
"""
def _get_registry(self):
"""Get tool registry lazily."""
if self._registry is None:
try:
from praisonaiagents.tools import get_registry
self._registry = get_registry()
except ImportError:
self.print_status(
"Tools require praisonaiagents. Install with: pip install praisonaiagents",
"error"
)
return None
return self._registry
def _get_builtin_tools(self) -> Dict[str, Any]:
"""Get dictionary of built-in tools."""
tools = {}
try:
from praisonaiagents.tools import TOOL_MAPPINGS
tools.update(TOOL_MAPPINGS)
except ImportError:
pass
# Add common tool descriptions
tool_descriptions = {
"internet_search": "Search the internet using DuckDuckGo",
"calculator": "Perform mathematical calculations",
"read_file": "Read contents of a file",
"write_file": "Write content to a file",
"list_files": "List files in a directory",
"execute_code": "Execute Python code",
"shell_command": "Execute shell commands",
"wikipedia_search": "Search Wikipedia",
"arxiv_search": "Search arXiv papers",
"tavily_search": "Search using Tavily API",
"csv_read": "Read CSV files",
"json_read": "Read JSON files",
"yaml_read": "Read YAML files",
}
for name, desc in tool_descriptions.items():
if name not in tools:
tools[name] = {"description": desc, "available": name in tools}
return tools
def action_list(self, args: List[str], **kwargs) -> List[str]:
"""
List all available tools.
Returns:
List of tool names
"""
tools = self._get_builtin_tools()
self.print_status("\n🔧 Available Tools:", "info")
self.print_status("-" * 50, "info")
# Group by category
categories = {
"Search": ["internet_search", "wikipedia_search", "arxiv_search", "tavily_search"],
"File": ["read_file", "write_file", "list_files", "csv_read", "json_read", "yaml_read"],
"Code": ["execute_code", "shell_command", "calculator"],
}
for category, tool_names in categories.items():
self.print_status(f"\n {category}:", "info")
for name in tool_names:
if name in tools:
desc = tools[name].get("description", "No description") if isinstance(tools[name], dict) else "Available"
self.print_status(f" • {name}: {desc}", "info")
# List any uncategorized tools
categorized = set(t for tools_list in categories.values() for t in tools_list)
uncategorized = [t for t in tools.keys() if t not in categorized]
if uncategorized:
self.print_status("\n Other:", "info")
for name in uncategorized[:10]: # Limit to 10
self.print_status(f" • {name}", "info")
return list(tools.keys())
def action_info(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Show tool information.
Args:
args: List containing tool name
Returns:
Dictionary of tool info
"""
if not args:
self.print_status("Usage: praisonai tools info <tool_name>", "error")
return {}
tool_name = args[0]
try:
import praisonaiagents.tools as tools_module
if hasattr(tools_module, tool_name):
tool = getattr(tools_module, tool_name)
info = {
"name": tool_name,
"type": type(tool).__name__,
"doc": tool.__doc__ or "No documentation",
}
# Get function signature if available
if callable(tool):
import inspect
try:
sig = inspect.signature(tool)
info["signature"] = str(sig)
info["parameters"] = list(sig.parameters.keys())
except (ValueError, TypeError):
pass
self.print_status(f"\n🔧 Tool: {tool_name}", "info")
self.print_status("-" * 40, "info")
for key, value in info.items():
if key == "doc":
self.print_status(f"\nDescription:\n{value}", "info")
else:
self.print_status(f" {key}: {value}", "info")
return info
else:
self.print_status(f"Tool '{tool_name}' not found", "error")
return {}
except ImportError:
self.print_status("Could not load tools module", "error")
return {}
def action_search(self, args: List[str], **kwargs) -> List[str]:
"""
Search tools by name or description.
Args:
args: List containing search query
Returns:
List of matching tool names
"""
if not args:
self.print_status("Usage: praisonai tools search <query>", "error")
return []
query = ' '.join(args).lower()
tools = self._get_builtin_tools()
matches = []
for name, info in tools.items():
desc = info.get("description", "") if isinstance(info, dict) else ""
if query in name.lower() or query in desc.lower():
matches.append(name)
if matches:
self.print_status(f"\n🔍 Tools matching '{query}':", "info")
for name in matches:
self.print_status(f" • {name}", "info")
else:
self.print_status(f"No tools found matching '{query}'", "warning")
return matches
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute tools command action."""
return super().execute(action, action_args, **kwargs)
File diff suppressed because it is too large. Load diff
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more