Compare commits

...
24 Commits
Author SHA1 Message Date
Mervin Praison bc23d3a104 Release v2.3.9 2025-12-16 08:18:09 +00:00
Mervin Praison bb4e1d8b9e Release v2.3.8 2025-12-16 07:55:55 +00:00
Mervin Praison 69740baa7c Release v2.3.7 2025-12-16 07:46:40 +00:00
Mervin Praison 5035978a29 fix: resolve Mintlify parsing errors
- Fix corrupted/duplicated database service in docker-compose.dev.yml
- Fix MDX parsing error in tests/README.md (<1s -> under 1s)
2025-12-15 23:11:04 +00:00
Mervin Praison 4ddb043324 Release v2.3.7 2025-12-15 22:49:12 +00:00
Mervin Praison 337014f4db planning example 2025-12-15 22:25:09 +00:00
Mervin Praison 35c371b25e update 2025-12-15 22:22:42 +00:00
Mervin Praison bba75d9fdd Release v2.3.6 2025-12-15 22:16:57 +00:00
Mervin Praison bf8cd488da feat: add planning tools and reasoning mode for research-enabled plan creation
- Add planning_tools and planning_reasoning parameters to PraisonAIAgents
- Add RESEARCH_TOOLS list for safe planning research tools (web_search, read_file, etc.)
- Add tools, reasoning, and max_reasoning_steps parameters to PlanningAgent
- Add _get_agent() method to PlanningAgent for tool-enabled planning with Agent.chat()
- Update plan creation to use Agent with tools when available, fallback to bare LLM
- Add Planning
2025-12-15 22:07:59 +00:00
Mervin Praison 28905db46d g 2025-12-15 21:27:32 +00:00
Mervin Praison 3e7aa44dfa docs: simplify feature descriptions by removing tool comparisons
- Remove references to Cursor, Windsurf, Claude Code, Gemini CLI, Codex CLI, and Cascade from feature descriptions
- Keep feature descriptions focused on PraisonAI's own capabilities
- Update Planning Mode, Agent Memory, Rules & Instructions, Auto-Generated Memories, Workflows, and Hooks sections
2025-12-15 21:26:04 +00:00
Mervin Praison 2f7a65ab92 update 2025-12-15 21:25:41 +00:00
Mervin Praison e6e2379b02 update 2025-12-15 21:25:29 +00:00
Mervin Praison f31f9a04aa update 2025-12-15 21:23:46 +00:00
Mervin Praison 5a8adf295e Release v2.3.5 2025-12-15 21:04:45 +00:00
Mervin Praison 8dc243fd4f feat: Add Planning Mode - plan before execution like Cursor, Windsurf, Claude Code
- Add planning module with PlanningAgent, Plan, PlanStep, TodoList, TodoItem
- Add PlanStorage for persisting plans to .praison/plans/
- Add ApprovalCallback for plan approval flow
- Add plan_mode to Agent for read-only tool filtering
- Add planning, planning_llm, auto_approve_plan params to PraisonAIAgents
- Add 71 comprehensive tests for planning mode
- Update READMEs with Planning Mode documentation
- Bump version to 0.0.181
2025-12-15 17:51:08 +00:00
Mervin Praison 548b4afb88 Release v2.3.4 2025-12-15 16:39:58 +00:00
Mervin Praison ffa938b3df docs: add advanced rules features and new memory capabilities
- Add local override files support (PRAISON.local.md, CLAUDE.local.md) with higher priority
- Add @import syntax for including other files in rules (like Claude Code)
- Add git root discovery for monorepo support with fallback to subprocess
- Add auto-discovery of modular rules directories (.claude/rules/, .windsurf/rules/, .cursor/rules/)
- Add character limits (12000) for context window management with truncation
- Add AutoMemory for
2025-12-15 16:39:04 +00:00
Mervin Praison 8a9beed264 docs: add advanced memory features and rules management system
- Add session save/resume, context compression, and checkpointing (inspired by Gemini CLI)
- Add RulesManager for auto-discovery of instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, .windsurfrules)
- Add slash commands for memory management (/memory show, /memory save, /memory compress, etc.)
- Add rules context injection into agent system prompts with activation modes (always, glob, manual, ai_decision)
- Add program
2025-12-15 16:17:25 +00:00
Mervin Praison daca5a1762 test: add multi-agent memory sharing and integration tests
- Add TestMultiAgentMemorySharing class with tests for shared/isolated memory between agents
- Test agents with same user_id share memory (short_term, long_term, entities)
- Test agents with different user_id have isolated memory
- Test memory context injection into system prompt
- Test memory display info shows correct counts
- Add TestMultiAgentMemoryIntegration class for LLM integration tests
- Test multi-agent memory sharing with OpenA
2025-12-15 15:00:16 +00:00
Mervin Praison b183ac5cbd Release v2.3.3 2025-12-15 14:55:30 +00:00
Mervin Praison 5f57e1d2f1 feat: add memory info display and improve memory integration with system prompts
- Add _display_memory_info() method to show memory stats in rich panel (long-term, short-term, entities, storage path)
- Disable system prompt caching when memory is enabled (dynamic context)
- Inject memory context into system prompt with "## Memory" section
- Display memory info on first chat when verbose=True
- Add integration tests for memory with OpenAI API (injection and persistence)
- Update README with zero
2025-12-15 14:52:09 +00:00
Mervin Praison 5114165944 feat: add zero-dependency file-based memory system for all agents
- Add FileMemory class with JSON-based persistent storage (no extra dependencies)
- Add _init_memory(), get_memory_context(), store_memory() methods to Agent class
- Support memory types: short_term, long_term, entity, episodic
- Add memory=True/False/"file"/"sqlite"/"chromadb"/"mem0" configuration options
- Add lazy import for Memory class to avoid dependency issues
- Update README with memory usage examples and provider comparison
2025-12-15 14:37:33 +00:00
Mervin Praison 389442ea04 feat: add Claude Memory Tool support for persistent cross-conversation memory
- Add claude_memory parameter to Agent and LLM classes
- Add ClaudeMemoryTool with file-based persistent memory in .praison/claude_memory/
- Add _supports_claude_memory(), _is_memory_tool_call(), _execute_memory_tool_call() methods
- Add automatic beta header injection for Anthropic models
- Add memory tool definition to completion params when enabled
- Add ClaudeMemoryTool to TOOL_MAPPINGS with class-level import
- Update
2025-12-15 13:48:35 +00:00
65 changed files with 17716 additions and 70 deletions

No files matched your search

+285 -3
View File
@@ -58,6 +58,12 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
- 🌐 **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)
## Using Python Code
@@ -101,7 +107,34 @@ Run:
python app.py
```
### 3. Deep Research Agent
### 3. Agent with Planning Mode
Enable planning for any agent - the agent creates a plan, then executes step by step:
```python
from praisonaiagents import Agent
def search_web(query: str) -> str:
return f"Search results for: {query}"
agent = Agent(
name="AI Assistant",
instructions="Research and write about topics",
planning=True, # Enable planning mode
planning_tools=[search_web], # Tools for planning research
planning_reasoning=True # Chain-of-thought reasoning
)
result = agent.start("Research AI trends in 2025 and write a summary")
```
**What happens:**
1. 📋 Agent creates a multi-step plan
2. 🚀 Executes each step sequentially
3. 📊 Shows progress with context passing
4. ✅ Returns final result
### 4. Deep Research Agent
Automated research with real-time streaming, web search, and citations using OpenAI or Gemini Deep Research APIs.
@@ -139,7 +172,7 @@ print(result.report)
- 🛠️ Built-in tools: web search, code interpreter, MCP, file search
- 🔄 Automatic provider detection from model name
### 4. Query Rewriter Agent
### 5. Query Rewriter Agent
Transform user queries to improve RAG retrieval quality using multiple strategies.
@@ -174,6 +207,160 @@ result = agent.rewrite("What about cost?", chat_history=[...])
- **CONTEXTUAL**: Resolve references using conversation history
- **AUTO**: Automatically detect best strategy
### 6. Agent Memory (Zero Dependencies)
Enable persistent memory for agents - works out of the box without any extra packages.
```python
from praisonaiagents import Agent
from praisonaiagents.memory import FileMemory
# Enable memory with a single parameter
agent = Agent(
name="Personal Assistant",
instructions="You are a helpful assistant that remembers user preferences.",
memory=True, # Enables file-based memory (no extra deps!)
user_id="user123" # Isolate memory per user
)
# Memory is automatically injected into conversations
result = agent.start("My name is John and I prefer Python")
# Agent will remember this for future conversations
```
**Memory Types:**
- **Short-term**: Rolling buffer of recent context (auto-expires)
- **Long-term**: Persistent important facts (sorted by importance)
- **Entity**: People, places, organizations with attributes
- **Episodic**: Date-based interaction history
**Advanced Features:**
```python
from praisonaiagents.memory import FileMemory
memory = FileMemory(user_id="user123")
# Session Save/Resume
memory.save_session("project_session", conversation_history=[...])
memory.resume_session("project_session")
# Context Compression
memory.compress(llm_func=lambda p: agent.chat(p), max_items=10)
# Checkpointing
memory.create_checkpoint("before_refactor", include_files=["main.py"])
memory.restore_checkpoint("before_refactor", restore_files=True)
# Slash Commands
memory.handle_command("/memory show")
memory.handle_command("/memory save my_session")
```
**Storage Options:**
| Option | Dependencies | Description |
|--------|-------------|-------------|
| `memory=True` | None | File-based JSON storage (default) |
| `memory="file"` | None | Explicit file-based storage |
| `memory="sqlite"` | Built-in | SQLite with indexing |
| `memory="chromadb"` | chromadb | Vector/semantic search |
### 6. Rules & Instructions
PraisonAI auto-discovers instruction files from your project root and git root:
| File | Description | Priority |
|------|-------------|----------|
| `PRAISON.md` | PraisonAI native instructions | High |
| `PRAISON.local.md` | Local overrides (gitignored) | Higher |
| `CLAUDE.md` | Claude Code memory file | High |
| `CLAUDE.local.md` | Local overrides (gitignored) | Higher |
| `AGENTS.md` | OpenAI Codex CLI instructions | High |
| `GEMINI.md` | Gemini CLI memory file | High |
| `.cursorrules` | Cursor IDE rules | High |
| `.windsurfrules` | Windsurf IDE rules | High |
| `.claude/rules/*.md` | Claude Code modular rules | Medium |
| `.windsurf/rules/*.md` | Windsurf modular rules | Medium |
| `.cursor/rules/*.mdc` | Cursor modular rules | Medium |
| `.praison/rules/*.md` | Workspace rules | Medium |
| `~/.praison/rules/*.md` | Global rules | Low |
```python
from praisonaiagents import Agent
# Agent auto-discovers CLAUDE.md, AGENTS.md, GEMINI.md, etc.
agent = Agent(name="Assistant", instructions="You are helpful.")
# Rules are injected into system prompt automatically
```
**@Import Syntax:**
```markdown
# CLAUDE.md
See @README for project overview
See @docs/architecture.md for system design
@~/.praison/my-preferences.md
```
**Rule File Format (with YAML frontmatter):**
```markdown
---
description: Python coding guidelines
globs: ["**/*.py"]
activation: always # always, glob, manual, ai_decision
---
# Guidelines
- Use type hints
- Follow PEP 8
```
### 7. Auto-Generated Memories
```python
from praisonaiagents.memory import FileMemory, AutoMemory
memory = FileMemory(user_id="user123")
auto = AutoMemory(memory, enabled=True)
# Automatically extracts and stores memories from conversations
memories = auto.process_interaction(
"My name is John and I prefer Python for backend work"
)
# Extracts: name="John", preference="Python for backend"
```
### 8. Workflows
Create reusable multi-step workflows in `.praison/workflows/`:
```python
from praisonaiagents.memory import WorkflowManager
manager = WorkflowManager()
# Execute a workflow
result = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
variables={"environment": "production"}
)
```
### 9. Hooks
Configure in `.praison/hooks.json`:
```python
from praisonaiagents.memory import HooksManager
hooks = HooksManager()
# Register Python hooks
hooks.register("pre_write_code", lambda ctx: print(f"Writing {ctx['file']}"))
# Execute hooks
result = hooks.execute("pre_write_code", {"file": "main.py"})
```
## Using No Code
### Auto Mode:
@@ -228,6 +415,101 @@ praisonai research --query-rewrite --tools tools.py --save "Your research query"
praisonai research -v "Your research query"
```
### Planning Mode CLI:
```bash
# Enable planning mode - agent creates a plan before execution
praisonai "Research AI trends and write a summary" --planning
# Planning with tools for research
praisonai "Analyze market trends" --planning --planning-tools tools.py
# Planning with chain-of-thought reasoning
praisonai "Complex analysis task" --planning --planning-reasoning
# Auto-approve plans without confirmation
praisonai "Task" --planning --auto-approve-plan
```
### Memory CLI:
```bash
# Enable memory for agent (persists across sessions)
praisonai "My name is John" --memory
# Memory with user isolation
praisonai "Remember my preferences" --memory --user-id user123
# Memory management commands
praisonai memory show # Show memory statistics
praisonai memory add "User prefers Python" # Add to long-term memory
praisonai memory search "Python" # Search memories
praisonai memory clear # Clear short-term memory
praisonai memory clear all # Clear all memory
praisonai memory save my_session # Save session
praisonai memory resume my_session # Resume session
praisonai memory sessions # List saved sessions
praisonai memory checkpoint # Create checkpoint
praisonai memory restore <checkpoint_id> # Restore checkpoint
praisonai memory checkpoints # List checkpoints
praisonai memory help # Show all commands
```
### Rules CLI:
```bash
# List all loaded rules (from PRAISON.md, CLAUDE.md, etc.)
praisonai rules list
# Show specific rule details
praisonai rules show <rule_name>
# Create a new rule
praisonai rules create my_rule "Always use type hints"
# Delete a rule
praisonai rules delete my_rule
# Show rules statistics
praisonai rules stats
# Include manual rules with prompts
praisonai "Task" --include-rules security,testing
```
### Workflow CLI:
```bash
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
```
### Hooks CLI:
```bash
# List configured hooks
praisonai hooks list
# Show hooks statistics
praisonai hooks stats
# Create hooks.json template
praisonai hooks init
```
### Claude Memory Tool CLI:
```bash
# Enable Claude Memory Tool (Anthropic models only)
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
## Using JavaScript Code
```bash
@@ -859,7 +1141,7 @@ cd src/praisonai && uv publish
- 🎨 Interactive UIs
- 📄 YAML-based Configuration
- 🛠️ Custom Tool Integration
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
- 🔍 Internet Search Capability (Tavily, You.com, Exa, DuckDuckGo, Crawl4AI)
- 🖼️ Vision Language Model (VLM) Support
- 🎙️ Real-time Voice Interaction
+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.2" \
"praisonai>=2.3.9" \
"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.2" \
"praisonai>=2.3.9" \
"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.2" \
"praisonai>=2.3.9" \
"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.2" \
"praisonai>=2.3.9" \
"praisonai[ui]" \
"praisonai[crewai]"
+4 -15
View File
@@ -137,22 +137,11 @@ services:
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
database:
image: postgres:15-alpine
environment:
POSTGRES_DB: praisonai_dev
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
+1
View File
@@ -86,3 +86,4 @@ build
graph.py
chroma_db/
.qodo
*_COMPARISON_ANALYSIS.md
+442 -9
View File
@@ -84,6 +84,64 @@ agents = PraisonAIAgents(agents=[researcher, writer], tasks=[research_task, writ
result = agents.start()
```
### Planning Mode 🆕
#### Single Agent Planning (Simplest)
Enable planning for any single agent:
```python
from praisonaiagents import Agent
def search_web(query: str) -> str:
return f"Search results for: {query}"
agent = Agent(
name="AI Assistant",
instructions="Research and write about topics",
planning=True, # Enable planning mode
planning_tools=[search_web], # Tools for planning research
planning_reasoning=True # Chain-of-thought reasoning
)
result = agent.start("Research AI trends in 2025 and write a summary")
```
#### Multi-Agent Planning
Plan before execution with multiple agents:
```python
from praisonaiagents import Agent, Task, PraisonAIAgents
researcher = Agent(name="Researcher", role="Research Analyst")
writer = Agent(name="Writer", role="Content Writer")
research_task = Task(description="Research AI benefits", agent=researcher)
write_task = Task(description="Write summary", agent=writer)
agents = PraisonAIAgents(
agents=[researcher, writer],
tasks=[research_task, write_task],
planning=True, # Enable planning
planning_tools=[search_web], # Tools for planning research
planning_reasoning=True, # Chain-of-thought reasoning
auto_approve_plan=True # Auto-approve plans
)
result = agents.start()
```
**Features:**
- **Single Agent Planning**: `Agent(planning=True)` - simplest API
- **Multi-Agent Planning**: `PraisonAIAgents(planning=True)` - orchestrated
- **Planning Tools**: Research with tools before creating plans
- **Reasoning Mode**: Chain-of-thought planning with tool use
- **Todo Lists**: Auto-generated from plans with progress tracking
- **Read-Only Mode**: `plan_mode=True` restricts agents to safe tools
- **Approval Flow**: Review and approve plans before execution
- **Persistence**: Plans saved to `.praison/plans/` as markdown
---
## Agent Types
@@ -216,27 +274,307 @@ router = RouterAgent(
## Memory
Persistent memory with short-term, long-term, and graph memory support.
PraisonAI provides a comprehensive memory system.
### Quick Start (Zero Dependencies)
```python
from praisonaiagents import Agent, Memory
from praisonaiagents import Agent
from praisonaiagents.memory import FileMemory
# Simple file-based memory (no extra dependencies)
memory = FileMemory(user_id="user123")
agent = Agent(
name="Memory Agent",
instructions="You are a helpful assistant with memory.",
memory=memory # or memory=True for auto FileMemory
)
response = agent.chat("My name is John and I prefer Python")
# Memory automatically stores this context
```
### Advanced Memory (with providers)
```python
from praisonaiagents.memory import Memory
memory = Memory(
provider="chroma", # or "mongodb", "rag"
provider="chroma", # or "mongodb", "mem0", "rag"
use_short_term=True,
use_long_term=True
)
agent = Agent(name="Memory Agent", memory=memory)
```
### Memory Types
| Type | Description | Storage |
|------|-------------|---------|
| **Short-term** | Rolling buffer of recent context | JSON/SQLite |
| **Long-term** | Persistent facts and knowledge | JSON/SQLite/Vector DB |
| **Entity** | Named entities (people, places, concepts) | JSON/SQLite |
| **Episodic** | Date-based interaction memories | JSON files |
| **Graph** | Relationship-based memory | Mem0/Neo4j |
### Session Save/Resume
```python
from praisonaiagents.memory import FileMemory
memory = FileMemory(user_id="user123")
# Add context during conversation
memory.add_short_term("User is working on ML project")
memory.add_long_term("User prefers Python", importance=0.9)
# Save session for later
memory.save_session("ml_project", conversation_history=[...])
# Resume later
session_data = memory.resume_session("ml_project")
# List all sessions
sessions = memory.list_sessions()
```
### Context Compression
```python
# Auto-compress when memory gets full
memory.auto_compress_if_needed(threshold_percent=0.7)
# Manual compression with LLM summarization
def llm_summarize(prompt):
return agent.chat(prompt)
summary = memory.compress(llm_func=llm_summarize, max_items=10)
```
### Checkpointing
```python
# Create checkpoint before risky operations
checkpoint_id = memory.create_checkpoint("before_refactor", include_files=["main.py"])
# Restore if needed
memory.restore_checkpoint(checkpoint_id, restore_files=True)
# List checkpoints
checkpoints = memory.list_checkpoints()
```
### Memory Slash Commands
```python
# Handle slash commands programmatically
result = memory.handle_command("/memory show")
result = memory.handle_command("/memory add User likes coffee")
result = memory.handle_command("/memory search Python")
result = memory.handle_command("/memory save my_session")
result = memory.handle_command("/memory compress")
result = memory.handle_command("/memory checkpoint")
result = memory.handle_command("/memory help")
```
**Available Commands:**
- `/memory show` - Display stats and recent items
- `/memory add <content>` - Add to long-term memory
- `/memory clear [short|all]` - Clear memory
- `/memory search <query>` - Search memories
- `/memory save <name>` - Save session
- `/memory resume <name>` - Resume session
- `/memory sessions` - List saved sessions
- `/memory compress` - Compress short-term memory
- `/memory checkpoint [name]` - Create checkpoint
- `/memory restore <id>` - Restore checkpoint
- `/memory checkpoints` - List checkpoints
- `/memory refresh` - Reload from disk
---
## Rules & Instructions
PraisonAI automatically discovers and applies rules from multiple sources.
### Supported Instruction Files
| File | Description | Priority |
|------|-------------|----------|
| `PRAISON.md` | PraisonAI native instructions | High (500) |
| `PRAISON.local.md` | Local overrides (gitignored) | Higher (600) |
| `CLAUDE.md` | Claude Code memory file | High (500) |
| `CLAUDE.local.md` | Local overrides (gitignored) | Higher (600) |
| `AGENTS.md` | OpenAI Codex CLI instructions | High (500) |
| `GEMINI.md` | Gemini CLI memory file | High (500) |
| `.cursorrules` | Cursor IDE rules (legacy) | High (500) |
| `.windsurfrules` | Windsurf IDE rules (legacy) | High (500) |
| `.claude/rules/*.md` | Claude Code modular rules | Medium (50) |
| `.windsurf/rules/*.md` | Windsurf modular rules | Medium (50) |
| `.cursor/rules/*.mdc` | Cursor modular rules | Medium (50) |
| `.praison/rules/*.md` | Workspace rules | Medium (0) |
| `~/.praison/rules/*.md` | Global rules | Low (-1000) |
### Auto-Discovery
Rules are automatically loaded when you create an Agent:
```python
from praisonaiagents import Agent
# Agent auto-discovers CLAUDE.md, AGENTS.md, GEMINI.md, etc.
agent = Agent(
name="Memory Agent",
memory=memory
name="Assistant",
instructions="You are helpful."
)
# Rules are injected into system prompt automatically
```
### Rule File Format
Rules support YAML frontmatter for advanced configuration:
```markdown
---
description: Python coding guidelines
globs: ["**/*.py", "**/*.pyx"]
activation: always # always, glob, manual, ai_decision
priority: 10
---
# Python Guidelines
- Use type hints for all functions
- Follow PEP 8 style guide
- Write docstrings for public APIs
```
### Activation Modes
| Mode | Description |
|------|-------------|
| `always` | Always applied (default) |
| `glob` | Applied when file matches glob pattern |
| `manual` | Only when explicitly invoked via @mention |
| `ai_decision` | AI decides when to apply |
### Programmatic Rules Management
```python
from praisonaiagents.memory import RulesManager
rules = RulesManager(workspace_path="/path/to/project")
# Get all active rules
active = rules.get_active_rules()
# Get rules for specific file
python_rules = rules.get_rules_for_file("src/main.py")
# Build context for LLM
context = rules.build_rules_context(file_path="src/main.py")
# Create new rule
rules.create_rule(
name="testing",
content="Always write tests first",
globs=["**/*.test.*"],
activation="glob"
)
# Get stats
stats = rules.get_stats()
# {'total_rules': 5, 'root_rules': 3, 'workspace_rules': 2, ...}
```
### Storage Structure
```
project/
├── CLAUDE.md # Auto-loaded (Claude Code)
├── CLAUDE.local.md # Local overrides (gitignored)
├── AGENTS.md # Auto-loaded (Codex CLI)
├── GEMINI.md # Auto-loaded (Gemini CLI)
├── PRAISON.md # Auto-loaded (PraisonAI)
├── PRAISON.local.md # Local overrides (gitignored)
├── .claude/rules/ # Claude Code modular rules
├── .windsurf/rules/ # Windsurf rules
├── .cursor/rules/ # Cursor rules
├── .praison/
│ ├── rules/ # Workspace rules
│ │ ├── python.md
│ │ └── testing.md
│ ├── workflows/ # Reusable workflows
│ │ └── deploy.md
│ ├── hooks.json # Pre/post operation hooks
│ └── memory/
│ └── {user_id}/
│ ├── short_term.json
│ ├── long_term.json
│ ├── sessions/
│ └── checkpoints/
└── ~/.praison/
└── rules/ # Global rules
└── global.md
```
### @Import Syntax
Include other files in your rules:
```markdown
# CLAUDE.md
See @README for project overview
See @docs/architecture.md for system design
@~/.praison/my-preferences.md
```
### Auto-Generated Memories
```python
from praisonaiagents.memory import FileMemory, AutoMemory
memory = FileMemory(user_id="user123")
auto = AutoMemory(memory, enabled=True)
# Automatically extracts and stores memories from conversations
memories = auto.process_interaction(
"My name is John and I prefer Python for backend work"
)
# Extracts: name="John", preference="Python for backend"
```
### Workflows
Create reusable multi-step workflows:
```python
from praisonaiagents.memory import WorkflowManager
manager = WorkflowManager()
# Execute a workflow
result = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
variables={"environment": "production"}
)
```
**Memory Types:**
- **Short-term**: Recent conversation context
- **Long-term**: Persistent storage across sessions
- **Graph**: Relationship-based memory with Mem0
### Hooks
```python
from praisonaiagents.memory import HooksManager
hooks = HooksManager()
# Register Python hooks
hooks.register("pre_write_code", lambda ctx: print(f"Writing {ctx['file']}"))
# Or configure in .praison/hooks.json
```
---
@@ -319,6 +657,8 @@ agent = Agent(tools=[my_tool])
Connect to MCP servers for extended capabilities.
### MCP Client (Consume MCP Servers)
```python
from praisonaiagents import Agent, MCP
@@ -345,6 +685,31 @@ agent = Agent(
)
```
### MCP Server (Expose Tools as MCP Server)
Expose your Python functions as MCP tools for external clients (Claude Desktop, Cursor, etc.).
```python
from praisonaiagents.mcp import ToolsMCPServer
# Define your tools as regular Python functions
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() # Starts stdio server (for Claude Desktop)
# Or run as SSE server for web clients
# server.run_sse(host="0.0.0.0", port=8080)
```
---
## Native Web Search
@@ -433,6 +798,74 @@ result = agent.start("Summarize the key terms")
---
## Claude Memory Tool (Beta)
Enable Claude to store and retrieve information across conversations.
```python
from praisonaiagents import Agent
# Enable Claude memory (Anthropic models only)
agent = Agent(
name="Research Assistant",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=True
)
# Claude will automatically:
# 1. Check /memories directory before tasks
# 2. Store progress/learnings in files
# 3. Reference memories in future conversations
result = agent.start("Research AI trends and remember key findings")
```
**Features:**
- File-based persistent memory in `.praison/claude_memory/`
- Claude autonomously decides what to store/retrieve
- Supports: view, create, str_replace, insert, delete, rename commands
- Cross-conversation learning
**Supported Models:** Claude Sonnet 4, Claude Opus 4, Claude Haiku 4.5
---
## Memory (Zero Dependencies)
Enable persistent memory for agents without any extra packages.
```python
from praisonaiagents import Agent
# Simple: Enable file-based memory (no extra dependencies!)
agent = Agent(
name="Assistant",
memory=True # Uses FileMemory by default
)
# Store memories
agent.store_memory("User prefers dark mode", memory_type="short_term")
agent.store_memory("User's name is John", memory_type="long_term", importance=0.9)
# Get memory context for prompts
context = agent.get_memory_context(query="What does the user prefer?")
```
**Memory Types:**
- **Short-term**: Rolling buffer of recent context (auto-expires)
- **Long-term**: Persistent important facts
- **Entity**: People, places, organizations
- **Episodic**: Date-based interaction history
**Storage Providers:**
| Provider | Dependencies | Use Case |
|----------|-------------|----------|
| `memory=True` or `"file"` | None | Default, zero-config |
| `"sqlite"` | Built-in | Search, indexing |
| `"chromadb"` | chromadb | Semantic/vector search |
| `"mem0"` | mem0ai | Graph memory, cloud |
---
## Guardrails
Add safety checks and validation to agent responses.
@@ -0,0 +1,144 @@
"""Example: Expose custom tools as MCP server.
This example demonstrates how to create an MCP server that exposes
custom Python functions as MCP tools. These tools can then be used
by any MCP client (Claude Desktop, Cursor, etc.).
Usage:
# Run as stdio server (for Claude Desktop, etc.)
python mcp_server_example.py
# Run as SSE server (for web clients)
python mcp_server_example.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
"""
# This is a 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
# In production, use a proper math parser
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Note: 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()
# Import MCP server components
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()
+34
View File
@@ -0,0 +1,34 @@
"""Simple Planning Mode Example: Plan first, then execute todo items."""
from praisonaiagents import Agent
from praisonaiagents.planning import PlanningAgent, TodoList
# Step 1: Create agents
researcher = Agent(name="Researcher", role="Research Analyst", verbose=True)
writer = Agent(name="Writer", role="Content Writer", verbose=True)
# Step 2: Create a plan using PlanningAgent
planner = PlanningAgent(llm="gpt-4o-mini", verbose=1)
plan = planner.create_plan_sync(
request="Write a short article about the top 3 benefits of meditation",
agents=[researcher, writer],
context="Keep it concise"
)
# Step 3: Create todo list from the plan
todo = TodoList.from_plan(plan)
# Step 4: Execute each todo item one by one
for item in todo.items:
todo.start(item.id)
# Pick the right agent based on the item's assigned agent
if item.agent == "Researcher":
agent = researcher
else:
agent = writer
# Execute the task
result = agent.chat(item.description)
todo.complete(item.id)
+21
View File
@@ -0,0 +1,21 @@
"""Simplest Planning Mode Example - Just enable planning=True"""
from praisonaiagents import Agent, Task, PraisonAIAgents
# Create agents
researcher = Agent(name="Researcher", role="Research Analyst")
writer = Agent(name="Writer", role="Content Writer")
# Create tasks
task1 = Task(description="Research the benefits of meditation", agent=researcher)
task2 = Task(description="Write a short article about meditation benefits", agent=writer)
# Run with planning enabled - that's it!
agents = PraisonAIAgents(
agents=[researcher, writer],
tasks=[task1, task2],
planning=True,
auto_approve_plan=True # Auto-approve the generated plan
)
agents.start()
@@ -55,6 +55,32 @@ except ImportError:
from .session import Session
from .memory.memory import Memory
from .guardrails import GuardrailResult, LLMGuardrail
# Planning mode support
try:
from .planning import (
Plan,
PlanStep,
TodoList,
TodoItem,
PlanStorage,
PlanningAgent,
ApprovalCallback,
READ_ONLY_TOOLS,
RESTRICTED_TOOLS,
)
_planning_available = True
except ImportError:
_planning_available = False
Plan = None
PlanStep = None
TodoList = None
TodoItem = None
PlanStorage = None
PlanningAgent = None
ApprovalCallback = None
READ_ONLY_TOOLS = []
RESTRICTED_TOOLS = []
from .agent.handoff import Handoff, handoff, handoff_filters, RECOMMENDED_PROMPT_PREFIX, prompt_with_handoff_instructions
# Flow display
try:
@@ -237,7 +237,12 @@ class Agent:
api_key: Optional[str] = None,
web_search: Optional[Union[bool, Dict[str, Any]]] = None,
web_fetch: Optional[Union[bool, Dict[str, Any]]] = None,
prompt_caching: Optional[bool] = None
prompt_caching: Optional[bool] = None,
claude_memory: Optional[Union[bool, Any]] = None,
plan_mode: bool = False,
planning: bool = False,
planning_tools: Optional[List[Any]] = None,
planning_reasoning: bool = False
):
"""Initialize an Agent instance.
@@ -406,7 +411,8 @@ class Agent:
metrics=metrics,
web_search=web_search,
web_fetch=web_fetch,
prompt_caching=prompt_caching
prompt_caching=prompt_caching,
claude_memory=claude_memory
)
self._using_custom_llm = True
except ImportError as e:
@@ -444,6 +450,7 @@ class Agent:
llm_params['web_search'] = web_search
llm_params['web_fetch'] = web_fetch
llm_params['prompt_caching'] = prompt_caching
llm_params['claude_memory'] = claude_memory
self.llm_instance = LLM(**llm_params)
self._using_custom_llm = True
@@ -480,7 +487,8 @@ class Agent:
self.max_iter = max_iter
self.max_rpm = max_rpm
self.max_execution_time = max_execution_time
self.memory = memory
self._memory_instance = None
self._init_memory(memory, user_id)
self.verbose = verbose
self.allow_delegation = allow_delegation
self.step_callback = step_callback
@@ -520,9 +528,19 @@ Your Goal: {self.goal}
# Store user_id
self.user_id = user_id or "praison"
self.reasoning_steps = reasoning_steps
self.plan_mode = plan_mode # Read-only mode for planning
self.planning = planning # Enable planning mode
self.planning_tools = planning_tools # Tools for planning phase
self.planning_reasoning = planning_reasoning # Enable reasoning during planning
self._planning_agent = None # Lazy loaded PlanningAgent
self.web_search = web_search
self.web_fetch = web_fetch
self.prompt_caching = prompt_caching
self.claude_memory = claude_memory
# Initialize rules manager for persistent context (like Cursor/Windsurf)
self._rules_manager = None
self._init_rules_manager()
# Handle web_search fallback: inject DuckDuckGo tool for unsupported models
if web_search and not self._model_supports_web_search():
@@ -601,6 +619,37 @@ Your Goal: {self.goal}
self._agent_id = str(uuid.uuid4())
return self._agent_id
def get_available_tools(self) -> List[Any]:
"""
Get tools available to this agent, filtered by plan_mode if enabled.
In plan_mode, only read-only tools are available to prevent
modifications during the planning phase.
Returns:
List of available tools
"""
if not self.plan_mode:
return self.tools
# Filter to read-only tools only
from ..planning import READ_ONLY_TOOLS, RESTRICTED_TOOLS
filtered_tools = []
for tool in self.tools:
tool_name = getattr(tool, '__name__', str(tool)).lower()
# Check if tool is in restricted list
is_restricted = any(
restricted.lower() in tool_name
for restricted in RESTRICTED_TOOLS
)
if not is_restricted:
filtered_tools.append(tool)
return filtered_tools
def _model_supports_web_search(self) -> bool:
"""
Check if the agent's model supports native web search via LiteLLM.
@@ -664,6 +713,200 @@ Your Goal: {self.goal}
return supports_prompt_caching(model_name)
def _init_rules_manager(self):
"""
Initialize RulesManager for persistent rules/instructions.
Automatically discovers rules from:
- ~/.praison/rules/ (global)
- .praison/rules/ (workspace)
- Subdirectory rules
"""
try:
from ..memory.rules_manager import RulesManager
import os
# Get workspace path (current working directory)
workspace_path = os.getcwd()
self._rules_manager = RulesManager(
workspace_path=workspace_path,
verbose=1 if self.verbose else 0
)
# Log discovered rules
stats = self._rules_manager.get_stats()
if stats["total_rules"] > 0:
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
except ImportError:
logging.debug("RulesManager not available")
self._rules_manager = None
except Exception as e:
logging.debug(f"Could not initialize RulesManager: {e}")
self._rules_manager = None
def get_rules_context(self, file_path: Optional[str] = None, include_manual: Optional[List[str]] = None) -> str:
"""
Get rules context for the current conversation.
Args:
file_path: Optional file path for glob-based rule matching
include_manual: Optional list of manual rule names to include (via @mention)
Returns:
Formatted rules context string
"""
if not self._rules_manager:
return ""
return self._rules_manager.build_rules_context(
file_path=file_path,
include_manual=include_manual
)
def _init_memory(self, memory, user_id: Optional[str] = None):
"""
Initialize memory based on the memory parameter.
Args:
memory: Can be:
- True: Use FileMemory with default settings
- False/None: No memory
- "file": Use FileMemory
- "sqlite": Use existing Memory class with SQLite
- dict: Configuration for memory
- Memory/FileMemory instance: Use directly
user_id: User identifier for memory isolation
"""
self.memory = memory
if memory is None or memory is False:
self._memory_instance = None
return
# Determine user_id
mem_user_id = user_id or getattr(self, 'user_id', None) or "default"
if memory is True or memory == "file":
# Use FileMemory (zero dependencies)
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(
user_id=mem_user_id,
verbose=1 if getattr(self, 'verbose', False) else 0
)
elif isinstance(memory, str) and memory in ("sqlite", "chromadb", "mem0", "mongodb"):
# Use full Memory class with specific provider
try:
from ..memory.memory import Memory
config = {"provider": memory if memory != "sqlite" else "rag"}
self._memory_instance = Memory(config)
except ImportError:
logging.warning(f"Memory provider '{memory}' requires additional dependencies. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
elif isinstance(memory, dict):
# Configuration dict
provider = memory.get("provider", "file")
if provider == "file":
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(
user_id=memory.get("user_id", mem_user_id),
config=memory
)
else:
try:
from ..memory.memory import Memory
self._memory_instance = Memory(memory)
except ImportError:
logging.warning("Full Memory class requires additional dependencies. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
else:
# Assume it's already a memory instance
self._memory_instance = memory
def get_memory_context(self, query: Optional[str] = None) -> str:
"""
Get memory context for the current conversation.
Args:
query: Optional query to focus the context
Returns:
Formatted memory context string
"""
if not self._memory_instance:
return ""
if hasattr(self._memory_instance, 'get_context'):
return self._memory_instance.get_context(query=query)
return ""
def store_memory(self, content: str, memory_type: str = "short_term", **kwargs):
"""
Store content in memory.
Args:
content: Content to store
memory_type: Type of memory (short_term, long_term, entity, episodic)
**kwargs: Additional arguments for the memory method
"""
if not self._memory_instance:
return
if memory_type == "short_term" and hasattr(self._memory_instance, 'add_short_term'):
self._memory_instance.add_short_term(content, **kwargs)
elif memory_type == "long_term" and hasattr(self._memory_instance, 'add_long_term'):
self._memory_instance.add_long_term(content, **kwargs)
elif memory_type == "entity" and hasattr(self._memory_instance, 'add_entity'):
self._memory_instance.add_entity(content, **kwargs)
elif memory_type == "episodic" and hasattr(self._memory_instance, 'add_episodic'):
self._memory_instance.add_episodic(content, **kwargs)
def _display_memory_info(self):
"""Display memory information to user in a friendly format."""
if not self._memory_instance:
return
# Only display once per chat session
if hasattr(self, '_memory_displayed') and self._memory_displayed:
return
self._memory_displayed = True
stats = self._memory_instance.get_stats()
short_count = stats.get('short_term_count', 0)
long_count = stats.get('long_term_count', 0)
entity_count = stats.get('entity_count', 0)
storage_path = stats.get('storage_path', '')
total_memories = short_count + long_count + entity_count
if total_memories > 0:
from rich.panel import Panel
from rich.text import Text
# Build memory info text
info_parts = []
if long_count > 0:
info_parts.append(f"💾 {long_count} long-term")
if short_count > 0:
info_parts.append(f"⚡ {short_count} short-term")
if entity_count > 0:
info_parts.append(f"👤 {entity_count} entities")
memory_text = Text()
memory_text.append("🧠 Memory loaded: ", style="bold cyan")
memory_text.append(" | ".join(info_parts))
memory_text.append(f"\n📁 Storage: {storage_path}", style="dim")
self.console.print(Panel(
memory_text,
title="[bold]Agent Memory[/bold]",
border_style="cyan",
expand=False
))
@property
def llm_model(self):
"""Unified property to get the LLM model regardless of configuration type.
@@ -910,17 +1153,35 @@ Your Goal: {self.goal}
if not self.use_system_prompt:
return None
# Check cache first
tools_key = self._get_tools_cache_key(tools)
cache_key = f"{self.role}:{self.goal}:{tools_key}"
if cache_key in self._system_prompt_cache:
return self._system_prompt_cache[cache_key]
# Check cache first (skip cache if memory is enabled since context is dynamic)
if not self._memory_instance:
tools_key = self._get_tools_cache_key(tools)
cache_key = f"{self.role}:{self.goal}:{tools_key}"
if cache_key in self._system_prompt_cache:
return self._system_prompt_cache[cache_key]
else:
cache_key = None # Don't cache when memory is enabled
system_prompt = f"""{self.backstory}\n
Your Role: {self.role}\n
Your Goal: {self.goal}"""
# Add rules context if rules manager is enabled
if self._rules_manager:
rules_context = self.get_rules_context()
if rules_context:
system_prompt += f"\n\n## Rules (Guidelines you must follow)\n{rules_context}"
# Add memory context if memory is enabled
if self._memory_instance:
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
# Add tool usage instructions if tools are available
# Use provided tools or fall back to self.tools
tools_to_use = tools if tools is not None else self.tools
@@ -950,9 +1211,9 @@ Your Goal: {self.goal}"""
if tool_names:
system_prompt += f"\n\nYou have access to the following tools: {', '.join(tool_names)}. Use these tools when appropriate to help complete your tasks. Always use tools when they can help provide accurate information or perform actions."
# Cache the generated system prompt
# Cache the generated system prompt (only if cache_key is set, i.e., memory not enabled)
# Simple cache size limit to prevent unbounded growth
if len(self._system_prompt_cache) < self._max_cache_size:
if cache_key and len(self._system_prompt_cache) < self._max_cache_size:
self._system_prompt_cache[cache_key] = system_prompt
return system_prompt
@@ -2238,10 +2499,88 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def run(self, prompt: str, **kwargs):
"""Alias for start() method"""
return self.start(prompt, **kwargs)
return self.start(prompt, **kwargs)
def _get_planning_agent(self):
"""Lazy load PlanningAgent for planning mode."""
if self._planning_agent is None and self.planning:
from ..planning import PlanningAgent
self._planning_agent = PlanningAgent(
llm=self.llm if hasattr(self, 'llm') else (self.llm_instance.model if hasattr(self, 'llm_instance') else "gpt-4o-mini"),
tools=self.planning_tools,
reasoning=self.planning_reasoning,
verbose=1 if self.verbose else 0
)
return self._planning_agent
def _start_with_planning(self, prompt: str, **kwargs):
"""Execute with planning mode - creates plan then executes each step."""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
# Step 1: Create the plan
console.print("\n[bold blue]📋 PLANNING PHASE[/bold blue]")
console.print("[dim]Creating implementation plan...[/dim]\n")
planner = self._get_planning_agent()
plan = planner.create_plan_sync(request=prompt, agents=[self])
if not plan or not plan.steps:
console.print("[yellow]⚠️ Planning failed, falling back to direct execution[/yellow]")
return self.chat(prompt, **kwargs)
# Display the plan
console.print(Panel(
Markdown(plan.to_markdown()),
title="[bold green]Generated Plan[/bold green]",
border_style="green"
))
# Step 2: Execute each step
console.print("\n[bold blue]🚀 EXECUTION PHASE[/bold blue]\n")
results = []
context = ""
for i, step in enumerate(plan.steps):
progress = (i + 1) / len(plan.steps)
bar_length = 30
filled = int(bar_length * progress)
bar = "█" * filled + "░" * (bar_length - filled)
console.print(f"[dim]Progress: [{bar}] {progress * 100:.0f}%[/dim]")
console.print(f"\n[bold]📌 Step {i + 1}/{len(plan.steps)}:[/bold] {step.description[:60]}...")
# Build prompt with context from previous steps
step_prompt = step.description
if context:
step_prompt = f"{step.description}\n\nContext from previous steps:\n{context}"
# Execute the step
result = self.chat(step_prompt, **kwargs)
results.append(result)
# Update context for next step
context += f"\n\nStep {i + 1} result: {result[:500] if result else 'No result'}"
console.print(f" [green]✅ Completed[/green]")
console.print(f"\n[bold green]🎉 EXECUTION COMPLETE[/bold green]")
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
def start(self, prompt: str, **kwargs):
"""Start the agent with a prompt. This is a convenience method that wraps chat()."""
# 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))
@@ -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):
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):
# Add check at the start if memory is requested
if memory:
try:
@@ -213,6 +213,16 @@ class PraisonAIAgents:
self._state = {} # Add state storage at PraisonAIAgents level
# Initialize planning mode
self.planning = planning
self.planning_llm = planning_llm or "gpt-4o-mini"
self.auto_approve_plan = auto_approve_plan
self.planning_tools = planning_tools
self.planning_reasoning = planning_reasoning
self._current_plan = None
self._todo_list = None
self._planning_agent = None
# Initialize memory system
self.shared_memory = None
if memory:
@@ -929,9 +939,13 @@ Context:
task.context = []
# Add content to context
task.context.append(content)
# Run tasks as before
self.run_all_tasks()
# Planning Mode: Create plan and todo list before execution
if self.planning:
self._run_with_planning()
else:
# Run tasks as before
self.run_all_tasks()
# Auto-display token metrics if any agent has metrics=True
metrics_enabled = any(getattr(agent, 'metrics', False) for agent in self.agents)
@@ -1532,3 +1546,354 @@ Context:
else:
display_error(f"Invalid protocol: {protocol}. Choose 'http' or 'mcp'.")
return None
# =========================================================================
# Planning Mode Properties and Methods
# =========================================================================
@property
def current_plan(self):
"""Get the current plan."""
return self._current_plan
@property
def todo_list(self):
"""Get the current todo list."""
return self._todo_list
def _get_planning_agent(self):
"""Lazy load PlanningAgent."""
if self._planning_agent is None and self.planning:
from ..planning import PlanningAgent
self._planning_agent = PlanningAgent(
llm=self.planning_llm,
read_only=True,
verbose=self.verbose,
tools=self.planning_tools,
reasoning=self.planning_reasoning
)
return self._planning_agent
async def _create_plan(self, request: str = None, context: str = None):
"""
Create an implementation plan.
Args:
request: The request/goal to plan for
context: Optional additional context
Returns:
Plan instance
"""
planner = self._get_planning_agent()
if planner is None:
return None
# Use first task description as request if not provided
if request is None and self.tasks:
first_task = list(self.tasks.values())[0]
request = first_task.description
plan = await planner.create_plan(
request=request,
agents=self.agents,
tasks=list(self.tasks.values()),
context=context
)
self._current_plan = plan
# Create todo list from plan
from ..planning import TodoList
self._todo_list = TodoList.from_plan(plan)
return plan
def _create_plan_sync(self, request: str = None, context: str = None):
"""
Synchronous version of _create_plan.
Args:
request: The request/goal to plan for
context: Optional additional context
Returns:
Plan instance
"""
planner = self._get_planning_agent()
if planner is None:
return None
# Use first task description as request if not provided
if request is None and self.tasks:
first_task = list(self.tasks.values())[0]
request = first_task.description
plan = planner.create_plan_sync(
request=request,
agents=self.agents,
tasks=list(self.tasks.values()),
context=context
)
self._current_plan = plan
# Create todo list from plan
from ..planning import TodoList
self._todo_list = TodoList.from_plan(plan)
return plan
async def _request_approval(self, plan):
"""
Request approval for a plan.
Args:
plan: Plan to approve
Returns:
True if approved, False if rejected
"""
if self.auto_approve_plan:
plan.approve()
return True
from ..planning import ApprovalCallback
callback = ApprovalCallback(auto_approve=self.auto_approve_plan)
return await callback.async_call(plan)
def _request_approval_sync(self, plan):
"""
Synchronous version of _request_approval.
Args:
plan: Plan to approve
Returns:
True if approved, False if rejected
"""
if self.auto_approve_plan:
plan.approve()
return True
from ..planning import ApprovalCallback
callback = ApprovalCallback(auto_approve=self.auto_approve_plan)
return callback(plan)
def get_plan_markdown(self) -> str:
"""
Get the current plan as markdown.
Returns:
Markdown string or empty string if no plan
"""
if self._current_plan:
return self._current_plan.to_markdown()
return ""
def get_todo_markdown(self) -> str:
"""
Get the current todo list as markdown.
Returns:
Markdown string or empty string if no todo list
"""
if self._todo_list:
return self._todo_list.to_markdown()
return ""
def update_plan_step_status(self, step_id: str, status: str) -> bool:
"""
Update the status of a plan step.
Args:
step_id: ID of the step to update
status: New status
Returns:
True if updated, False if not found
"""
if self._current_plan:
result = self._current_plan.update_step_status(step_id, status)
# Sync todo list
if self._todo_list:
self._todo_list.sync_with_plan(self._current_plan)
return result
return False
def _run_with_planning(self):
"""
Run tasks with planning mode enabled.
This method:
1. Creates a plan using PlanningAgent
2. Generates a todo list from the plan
3. Creates proper Task objects from plan steps
4. Executes each task using the full Task execution system
(with memory, callbacks, guardrails, structured output, etc.)
5. Tracks progress as items are completed
"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from ..task import Task
console = Console()
# Step 1: Create the plan
console.print("\n[bold blue]📋 PLANNING PHASE[/bold blue]")
console.print("[dim]Creating implementation plan...[/dim]\n")
# Build request from tasks
task_descriptions = [task.description for task in self.tasks.values()]
request = " AND ".join(task_descriptions)
plan = self._create_plan_sync(request=request)
if not plan:
console.print("[yellow]⚠️ Planning failed, falling back to normal execution[/yellow]")
self.run_all_tasks()
return
# Display the plan
console.print(Panel(
Markdown(plan.to_markdown()),
title="[bold green]Generated Plan[/bold green]",
border_style="green"
))
# Step 2: Request approval if not auto-approve
if not self.auto_approve_plan:
approved = self._request_approval_sync(plan)
if not approved:
console.print("[red]❌ Plan rejected. Aborting execution.[/red]")
return
else:
plan.approve()
console.print("[green]✅ Plan auto-approved[/green]")
# Step 3: Create todo list for tracking
from ..planning import TodoList
self._todo_list = TodoList.from_plan(plan)
console.print("\n[bold blue]📝 TODO LIST[/bold blue]")
console.print(Panel(
Markdown(self._todo_list.to_markdown()),
title="[bold cyan]Tasks to Complete[/bold cyan]",
border_style="cyan"
))
# Step 4: Create proper Task objects from plan steps
console.print("\n[bold blue]🚀 EXECUTION PHASE[/bold blue]\n")
# Map agent names to agent instances
agent_map = {agent.name: agent for agent in self.agents}
# Store original tasks and create new tasks from plan
original_tasks = self.tasks.copy()
self.tasks = {}
self.task_id_counter = 0
# Create Task objects from plan steps
plan_tasks = []
step_to_task = {} # Map step_id to task for context chaining
for i, step in enumerate(plan.steps):
# Get the appropriate agent
agent = agent_map.get(step.agent, self.agents[0] if self.agents else None)
if not agent:
console.print(f"[yellow]⚠️ No agent found for '{step.agent}', using first available[/yellow]")
agent = self.agents[0] if self.agents else None
if not agent:
console.print(f"[red]❌ No agents available for step: {step.description}[/red]")
continue
# Build context from dependencies (previous task results)
context = []
for dep_id in step.dependencies:
# Convert step_X format to actual step index
if dep_id.startswith("step_"):
try:
dep_index = int(dep_id.split("_")[1])
if dep_index < len(plan_tasks):
context.append(plan_tasks[dep_index])
except (ValueError, IndexError):
pass
elif dep_id in step_to_task:
context.append(step_to_task[dep_id])
# Find matching original task for additional config (memory, callbacks, etc.)
original_task = None
for orig_task in original_tasks.values():
if orig_task.agent and orig_task.agent.name == agent.name:
original_task = orig_task
break
# Create Task with full features from original task if available
task = Task(
description=step.description,
expected_output=f"Complete: {step.description}",
agent=agent,
name=f"Plan Step {i + 1}",
tools=agent.tools if agent.tools else [],
context=context if context else None,
# Inherit from original task if available
memory=original_task.memory if original_task else None,
callback=original_task.callback if original_task else None,
guardrail=original_task.guardrail if original_task else None,
max_retries=original_task.max_retries if original_task else 3,
output_json=original_task.output_json if original_task else None,
output_pydantic=original_task.output_pydantic if original_task else None,
config=original_task.config if original_task else {}
)
# Add task to our task list
task_id = self.add_task(task)
plan_tasks.append(task)
step_to_task[step.id] = task
# Step 5: Execute tasks using the proper Task execution system
for i, (task_id, task) in enumerate(self.tasks.items()):
# Update todo list progress
if i < len(self._todo_list.items):
item = self._todo_list.items[i]
# Display progress bar
progress = self._todo_list.progress
bar_length = 30
filled = int(bar_length * progress)
bar = "█" * filled + "░" * (bar_length - filled)
console.print(f"[dim]Progress: [{bar}] {progress * 100:.0f}%[/dim]")
console.print(f"\n[bold]📌 Step {i + 1}/{len(self.tasks)}:[/bold] {task.description[:60]}...")
console.print(f"[dim] Agent: {task.agent.name if task.agent else 'Unknown'}[/dim]")
# Mark as in progress
self._todo_list.start(item.id)
# Execute using the full Task execution system
# This includes: memory, callbacks, guardrails, structured output, retry logic
try:
self.run_task(task_id)
if task.status == "completed":
if i < len(self._todo_list.items):
self._todo_list.complete(self._todo_list.items[i].id)
console.print("[green] ✅ Completed[/green]")
else:
console.print(f"[yellow] ⚠️ Task status: {task.status}[/yellow]")
except Exception as e:
console.print(f"[red] ❌ Error: {e}[/red]")
logger.error(f"Error executing plan task {task_id}: {e}")
# Final progress
completed_count = len([t for t in self.tasks.values() if t.status == "completed"])
console.print(f"\n[bold green]🎉 EXECUTION COMPLETE[/bold green]")
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
# Restore original tasks reference for result retrieval
self._plan_tasks = self.tasks.copy()
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
@@ -242,6 +242,7 @@ class LLM:
web_search: Optional[Union[bool, Dict[str, Any]]] = None,
web_fetch: Optional[Union[bool, Dict[str, Any]]] = None,
prompt_caching: Optional[bool] = None,
claude_memory: Optional[Union[bool, Any]] = None,
**extra_settings
):
# Configure logging only once at the class level
@@ -279,6 +280,8 @@ class LLM:
self.web_search = web_search
self.web_fetch = web_fetch
self.prompt_caching = prompt_caching
self.claude_memory = claude_memory
self._claude_memory_tool = None # Lazy initialized
self._console = None # Lazy load console when needed
self.chat_history = []
self.verbose = extra_settings.get('verbose', True)
@@ -426,6 +429,70 @@ class LLM:
from .model_capabilities import supports_prompt_caching
return supports_prompt_caching(self.model)
def _get_claude_memory_tool(self):
"""
Get or initialize the Claude Memory Tool instance.
Returns:
ClaudeMemoryTool instance if claude_memory is enabled, None otherwise
"""
if not self.claude_memory:
return None
if self._claude_memory_tool is None:
from ..tools.claude_memory_tool import ClaudeMemoryTool
if isinstance(self.claude_memory, bool):
# Create default memory tool
self._claude_memory_tool = ClaudeMemoryTool()
else:
# Use provided memory tool instance
self._claude_memory_tool = self.claude_memory
return self._claude_memory_tool
def _supports_claude_memory(self) -> bool:
"""
Check if the current model supports Claude Memory Tool.
Claude Memory Tool is only supported by Anthropic Claude models.
Returns:
bool: True if the model supports Claude Memory Tool
"""
if not self.claude_memory:
return False
return self._is_anthropic_model()
def _is_memory_tool_call(self, function_name: str) -> bool:
"""
Check if a tool call is for the Claude Memory Tool.
Args:
function_name: Name of the function being called
Returns:
bool: True if this is a memory tool call
"""
return function_name == "memory" and self._supports_claude_memory()
def _execute_memory_tool_call(self, arguments: Dict[str, Any]) -> str:
"""
Execute a Claude Memory Tool call.
Args:
arguments: The arguments from Claude's tool call
Returns:
Result string from the memory operation
"""
memory_tool = self._get_claude_memory_tool()
if not memory_tool:
return "Error: Memory tool not initialized"
return memory_tool.process_tool_call(arguments)
def _generate_ollama_tool_summary(self, tool_results: List[Any], response_text: str) -> Optional[str]:
"""
Generate a summary from tool results for Ollama to prevent infinite loops.
@@ -3287,6 +3354,28 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Web fetch enabled with tool: {web_fetch_tool}")
# Add Claude Memory Tool if enabled and model supports it
if self._supports_claude_memory():
memory_tool = self._get_claude_memory_tool()
if memory_tool:
# Add the memory tool definition
memory_tool_def = memory_tool.get_tool_definition()
# Add memory tool to existing tools or create tools list
if 'tools' in params and params['tools']:
params['tools'].append(memory_tool_def)
else:
params['tools'] = [memory_tool_def]
# Add the beta header for Anthropic
beta_header = memory_tool.get_beta_header()
if 'extra_headers' in params:
params['extra_headers']['anthropic-beta'] = beta_header
else:
params['extra_headers'] = {'anthropic-beta': beta_header}
logging.debug(f"Claude memory tool enabled with beta header: {beta_header}")
return params
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
@@ -1,9 +1,40 @@
"""
Model Context Protocol (MCP) integration for PraisonAI Agents.
This package provides classes and utilities for connecting to MCP servers
using different transport methods (stdio, SSE, etc.).
This package provides classes and utilities for:
- Connecting to MCP servers (as client) using different transports (stdio, SSE, etc.)
- Exposing Python functions as MCP servers for external clients
Client Usage:
from praisonaiagents.mcp import MCP
agent = Agent(
tools=MCP("npx -y @modelcontextprotocol/server-weather")
)
Server Usage:
from praisonaiagents.mcp import ToolsMCPServer
def my_tool(query: str) -> str:
'''Search for information.'''
return f"Results for {query}"
server = ToolsMCPServer(name="my-tools")
server.register_tool(my_tool)
server.run() # Starts MCP server
"""
from .mcp import MCP
from .mcp_server import ToolsMCPServer, launch_tools_mcp_server
from .mcp_utils import function_to_mcp_schema, get_tool_metadata, python_type_to_json_schema
__all__ = ["MCP"]
__all__ = [
# Client
"MCP",
# Server
"ToolsMCPServer",
"launch_tools_mcp_server",
# Utilities
"function_to_mcp_schema",
"get_tool_metadata",
"python_type_to_json_schema",
]
@@ -0,0 +1,356 @@
"""MCP Server wrapper for exposing PraisonAI tools as MCP servers.
This module provides a simple way to expose Python functions as MCP tools
that can be consumed by any MCP client (Claude Desktop, Cursor, etc.).
Usage:
from praisonaiagents.mcp import ToolsMCPServer
def search(query: str) -> str:
'''Search the web.'''
return f"Results for {query}"
server = ToolsMCPServer(name="my-tools")
server.register_tool(search)
server.run() # Starts MCP server (stdio by default)
"""
import asyncio
import inspect
import logging
from typing import Any, Callable, Dict, List, Optional
from .mcp_utils import function_to_mcp_schema
logger = logging.getLogger(__name__)
class ToolsMCPServer:
"""MCP Server that exposes Python functions as MCP tools.
This class wraps Python functions and exposes them as MCP tools
using the FastMCP library. It supports both sync and async functions.
Example:
server = ToolsMCPServer(name="my-tools")
def search(query: str) -> str:
'''Search the web.'''
return f"Results for {query}"
server.register_tool(search)
server.run() # Starts stdio server
"""
def __init__(
self,
name: str = "praisonai-tools",
tools: Optional[List[Callable]] = None,
debug: bool = False
):
"""Initialize the MCP server.
Args:
name: Name of the MCP server
tools: Optional list of tools to register on init
debug: Enable debug logging
"""
self.name = name
self.tools: List[Callable] = []
self._tool_map: Dict[str, Callable] = {}
self._fastmcp = None
self._debug = debug
if debug:
logger.setLevel(logging.DEBUG)
# Register initial tools if provided
if tools:
self.register_tools(tools)
def register_tool(self, func: Callable) -> None:
"""Register a single tool function.
Args:
func: The function to register as an MCP tool
"""
if not callable(func):
raise TypeError(f"Expected callable, got {type(func)}")
self.tools.append(func)
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
self._tool_map[tool_name] = func
logger.debug(f"Registered tool: {tool_name}")
def register_tools(self, funcs: List[Callable]) -> None:
"""Register multiple tool functions.
Args:
funcs: List of functions to register as MCP tools
"""
for func in funcs:
self.register_tool(func)
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Get MCP schemas for all registered tools.
Returns:
List of MCP tool schema dictionaries
"""
return [function_to_mcp_schema(tool) for tool in self.tools]
def get_tool_names(self) -> List[str]:
"""Get names of all registered tools.
Returns:
List of tool names
"""
return list(self._tool_map.keys())
def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Execute a registered tool synchronously.
Args:
tool_name: Name of the tool to execute
arguments: Arguments to pass to the tool
Returns:
Result from the tool execution
Raises:
ValueError: If tool is not found
"""
if tool_name not in self._tool_map:
raise ValueError(f"Tool '{tool_name}' not found")
func = self._tool_map[tool_name]
# Handle async functions
if asyncio.iscoroutinefunction(func):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(func(**arguments))
finally:
loop.close()
return func(**arguments)
async def execute_tool_async(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Execute a registered tool asynchronously.
Args:
tool_name: Name of the tool to execute
arguments: Arguments to pass to the tool
Returns:
Result from the tool execution
Raises:
ValueError: If tool is not found
"""
if tool_name not in self._tool_map:
raise ValueError(f"Tool '{tool_name}' not found")
func = self._tool_map[tool_name]
if asyncio.iscoroutinefunction(func):
return await func(**arguments)
# Run sync function in executor
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: func(**arguments))
def get_fastmcp(self):
"""Get or create FastMCP instance with registered tools.
Returns:
FastMCP instance with all tools registered
"""
if self._fastmcp is None:
self._fastmcp = self._create_fastmcp()
return self._fastmcp
def _create_fastmcp(self):
"""Create FastMCP instance and register all tools.
Returns:
Configured FastMCP instance
"""
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
raise ImportError(
"FastMCP not available. Install with: pip install mcp"
)
mcp = FastMCP(self.name)
# Register each tool with FastMCP
for func in self.tools:
self._register_tool_with_fastmcp(mcp, func)
return mcp
def _register_tool_with_fastmcp(self, mcp, func: Callable) -> None:
"""Register a single function with FastMCP.
Args:
mcp: FastMCP instance
func: Function to register
"""
# Get tool metadata
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
description = getattr(func, '__mcp_description__', None)
if description is None:
docstring = inspect.getdoc(func)
description = docstring.split('\n')[0] if docstring else tool_name
# Create wrapper that preserves signature
if asyncio.iscoroutinefunction(func):
@mcp.tool(name=tool_name, description=description)
async def tool_wrapper(**kwargs):
return await func(**kwargs)
else:
@mcp.tool(name=tool_name, description=description)
def tool_wrapper(**kwargs):
return func(**kwargs)
# Copy signature for proper schema generation
tool_wrapper.__signature__ = inspect.signature(func)
tool_wrapper.__annotations__ = getattr(func, '__annotations__', {})
def run(self, transport: str = "stdio") -> None:
"""Run the MCP server.
Args:
transport: Transport type - "stdio" or "sse"
"""
if transport == "stdio":
self.run_stdio()
elif transport == "sse":
self.run_sse()
else:
raise ValueError(f"Unknown transport: {transport}. Use 'stdio' or 'sse'")
def run_stdio(self) -> None:
"""Run the MCP server using stdio transport."""
mcp = self.get_fastmcp()
print(f"🚀 Starting MCP server '{self.name}' with stdio transport")
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
mcp.run()
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
"""Run the MCP server using SSE transport.
Args:
host: Host to bind to
port: Port to listen on
"""
try:
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.routing import Mount, Route
except ImportError as e:
raise ImportError(
f"SSE dependencies not available: {e}. "
"Install with: pip install uvicorn starlette"
)
mcp = self.get_fastmcp()
# Set up SSE transport
sse_path = "/sse"
messages_path = "/messages/"
sse_transport = SseServerTransport(messages_path)
async def handle_sse(request: Request):
async with sse_transport.connect_sse(
request.scope, request.receive, request._send
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options()
)
app = Starlette(
debug=self._debug,
routes=[
Route(sse_path, endpoint=handle_sse),
Mount(messages_path, app=sse_transport.handle_post_message),
]
)
print(f"🚀 Starting MCP server '{self.name}' with SSE transport")
print(f"📡 SSE endpoint: http://{host}:{port}{sse_path}")
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
uvicorn.run(app, host=host, port=port)
def launch_tools_mcp_server(
tools: Optional[List[Callable]] = None,
tool_names: Optional[List[str]] = None,
name: str = "praisonai-tools",
transport: str = "stdio",
host: str = "0.0.0.0",
port: int = 8080,
debug: bool = False
) -> None:
"""Convenience function to launch an MCP server with tools.
Args:
tools: List of tool functions to expose
tool_names: List of tool names to load from praisonaiagents.tools
name: Name of the MCP server
transport: Transport type - "stdio" or "sse"
host: Host for SSE transport
port: Port for SSE transport
debug: Enable debug logging
Example:
# With custom tools
def my_search(query: str) -> str:
return f"Results for {query}"
launch_tools_mcp_server(tools=[my_search])
# With built-in tools
launch_tools_mcp_server(tool_names=["tavily_search", "exa_search"])
"""
server = ToolsMCPServer(name=name, debug=debug)
# Register custom tools
if tools:
server.register_tools(tools)
# Load and register tools by name
if tool_names:
from praisonaiagents import tools as praison_tools
for tool_name in tool_names:
if hasattr(praison_tools, tool_name):
tool_func = getattr(praison_tools, tool_name)
if callable(tool_func):
server.register_tool(tool_func)
else:
logger.warning(f"Tool '{tool_name}' is not callable")
else:
logger.warning(f"Tool '{tool_name}' not found in praisonaiagents.tools")
if not server.tools:
raise ValueError("No tools registered. Provide 'tools' or 'tool_names'.")
# Run the server
if transport == "stdio":
server.run_stdio()
elif transport == "sse":
server.run_sse(host=host, port=port)
else:
raise ValueError(f"Unknown transport: {transport}")
@@ -0,0 +1,238 @@
"""MCP utility functions for converting Python functions to MCP tool schemas.
This module provides utilities to convert Python functions with type hints
into MCP-compatible tool schemas for exposing as MCP server tools.
Usage:
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def my_tool(query: str, max_results: int = 10) -> str:
'''Search for information.'''
return "results"
schema = function_to_mcp_schema(my_tool)
# Returns MCP-compatible tool schema
"""
import inspect
from typing import Any, Callable, Dict, List, Union, get_type_hints, get_origin, get_args
def python_type_to_json_schema(python_type: Any) -> Dict[str, Any]:
"""Convert a Python type annotation to JSON Schema.
Args:
python_type: A Python type annotation (e.g., str, int, List[str], Optional[int])
Returns:
JSON Schema dictionary representing the type
"""
# Handle None type
if python_type is None or python_type is type(None):
return {"type": "null"}
# Handle Any type
if python_type is Any:
return {}
# Handle basic types
type_mapping = {
str: {"type": "string"},
int: {"type": "integer"},
float: {"type": "number"},
bool: {"type": "boolean"},
bytes: {"type": "string"},
}
if python_type in type_mapping:
return type_mapping[python_type]
# Handle generic types (List, Dict, Optional, Union)
origin = get_origin(python_type)
args = get_args(python_type)
# Handle List[T]
if origin is list:
if args:
return {
"type": "array",
"items": python_type_to_json_schema(args[0])
}
return {"type": "array", "items": {}}
# Handle Dict[K, V]
if origin is dict:
return {"type": "object"}
# Handle Optional[T] (Union[T, None])
if origin is Union:
# Filter out None type
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
# Optional[T] case
return python_type_to_json_schema(non_none_args[0])
elif len(non_none_args) > 1:
# Union of multiple types
return {"oneOf": [python_type_to_json_schema(arg) for arg in non_none_args]}
return {}
# Handle tuple
if origin is tuple:
if args:
return {
"type": "array",
"items": [python_type_to_json_schema(arg) for arg in args]
}
return {"type": "array"}
# Handle set
if origin is set:
if args:
return {
"type": "array",
"items": python_type_to_json_schema(args[0]),
"uniqueItems": True
}
return {"type": "array", "uniqueItems": True}
# Default to object for unknown types
return {"type": "object"}
def get_tool_metadata(func: Callable) -> Dict[str, str]:
"""Extract metadata from a function for MCP tool registration.
Checks for custom attributes (__mcp_name__, __mcp_description__) first,
then falls back to function name and docstring.
Args:
func: The function to extract metadata from
Returns:
Dictionary with 'name' and 'description' keys
"""
# Get name - check for custom attribute first
name = getattr(func, '__mcp_name__', None) or func.__name__
# Get description - check for custom attribute first
description = getattr(func, '__mcp_description__', None)
if description is None:
# Extract from docstring
docstring = inspect.getdoc(func)
if docstring:
# Use first line of docstring as description
description = docstring.split('\n')[0].strip()
else:
# Fall back to function name
description = name
return {
"name": name,
"description": description
}
def function_to_mcp_schema(func: Callable) -> Dict[str, Any]:
"""Convert a Python function to an MCP tool schema.
This function inspects the function's signature, type hints, and docstring
to generate a complete MCP-compatible tool schema.
Args:
func: The function to convert
Returns:
MCP tool schema dictionary with:
- name: Tool name
- description: Tool description
- inputSchema: JSON Schema for parameters
Example:
def search(query: str, max_results: int = 10) -> str:
'''Search the web for information.'''
return "results"
schema = function_to_mcp_schema(search)
# {
# "name": "search",
# "description": "Search the web for information.",
# "inputSchema": {
# "type": "object",
# "properties": {
# "query": {"type": "string"},
# "max_results": {"type": "integer"}
# },
# "required": ["query"]
# }
# }
"""
# Get metadata
metadata = get_tool_metadata(func)
# Get function signature
sig = inspect.signature(func)
# Try to get type hints
try:
type_hints = get_type_hints(func)
except Exception:
type_hints = {}
# Build properties and required list
properties = {}
required = []
for param_name, param in sig.parameters.items():
# Skip self, cls, and special parameters
if param_name in ('self', 'cls'):
continue
# Skip *args and **kwargs
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
# Get type annotation
param_type = type_hints.get(param_name, param.annotation)
if param_type is inspect.Parameter.empty:
# No type annotation - default to string
properties[param_name] = {"type": "string"}
else:
properties[param_name] = python_type_to_json_schema(param_type)
# Check if parameter is required (no default value)
if param.default is inspect.Parameter.empty:
# Check if it's Optional type
origin = get_origin(param_type) if param_type is not inspect.Parameter.empty else None
args = get_args(param_type) if param_type is not inspect.Parameter.empty else ()
is_optional = origin is Union and type(None) in args
if not is_optional:
required.append(param_name)
# Build the schema
schema = {
"name": metadata["name"],
"description": metadata["description"],
"inputSchema": {
"type": "object",
"properties": properties,
"required": required
}
}
return schema
def functions_to_mcp_tools(functions: List[Callable]) -> List[Dict[str, Any]]:
"""Convert multiple functions to MCP tool schemas.
Args:
functions: List of functions to convert
Returns:
List of MCP tool schemas
"""
return [function_to_mcp_schema(func) for func in functions]
@@ -8,8 +8,83 @@ This module provides memory management capabilities including:
- User memory for preferences/history
- Quality-based storage decisions
- Graph memory support via Mem0
- Session save/resume (like Gemini CLI)
- Context compression (like Gemini CLI)
- Checkpointing (like Gemini CLI)
- Rules management (like Cursor/Windsurf)
- Auto-generated memories (like Windsurf Cascade)
- Workflows (like Windsurf)
- Hooks (like Windsurf Cascade Hooks)
Memory Providers:
- FileMemory: Zero-dependency JSON file-based storage (default)
- Memory: Full-featured with SQLite, ChromaDB, Mem0, MongoDB support
- RulesManager: Rules/instructions file management
- AutoMemory: Automatic memory extraction from conversations
- WorkflowManager: Multi-step workflow execution
- HooksManager: Pre/post operation hooks
"""
from .memory import Memory
from .file_memory import FileMemory, create_memory
from .rules_manager import RulesManager, Rule, create_rules_manager
__all__ = ["Memory"]
# Lazy imports for optional modules to avoid dependency issues and improve startup time
def __getattr__(name):
if name == "Memory":
from .memory import Memory
return Memory
if name == "AutoMemory":
from .auto_memory import AutoMemory
return AutoMemory
if name == "AutoMemoryExtractor":
from .auto_memory import AutoMemoryExtractor
return AutoMemoryExtractor
if name == "create_auto_memory":
from .auto_memory import create_auto_memory
return create_auto_memory
if name == "WorkflowManager":
from .workflows import WorkflowManager
return WorkflowManager
if name == "Workflow":
from .workflows import Workflow
return Workflow
if name == "WorkflowStep":
from .workflows import WorkflowStep
return WorkflowStep
if name == "create_workflow_manager":
from .workflows import create_workflow_manager
return create_workflow_manager
if name == "HooksManager":
from .hooks import HooksManager
return HooksManager
if name == "HookResult":
from .hooks import HookResult
return HookResult
if name == "create_hooks_manager":
from .hooks import create_hooks_manager
return create_hooks_manager
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Core memory
"FileMemory",
"Memory",
"create_memory",
# Rules management
"RulesManager",
"Rule",
"create_rules_manager",
# Auto memory
"AutoMemory",
"AutoMemoryExtractor",
"create_auto_memory",
# Workflows
"WorkflowManager",
"Workflow",
"WorkflowStep",
"create_workflow_manager",
# Hooks
"HooksManager",
"HookResult",
"create_hooks_manager",
]
@@ -0,0 +1,382 @@
"""
Auto-Generated Memory System for PraisonAI Agents.
Provides automatic memory generation similar to Windsurf's Cascade,
where the AI automatically decides what context is worth remembering.
Features:
- Automatic extraction of important facts from conversations
- Entity recognition and storage
- Preference detection
- Lazy evaluation to avoid performance impact
- Configurable importance thresholds
This module is designed to be lightweight and only activates
when explicitly enabled to avoid impacting performance.
"""
import re
import logging
from typing import Any, Dict, List, Optional, Callable, TYPE_CHECKING
if TYPE_CHECKING:
from .file_memory import FileMemory
logger = logging.getLogger(__name__)
class AutoMemoryExtractor:
"""
Automatically extracts memorable content from conversations.
Uses pattern matching for fast extraction without LLM calls,
with optional LLM enhancement for better accuracy.
Example:
```python
extractor = AutoMemoryExtractor()
# Extract from conversation
memories = extractor.extract(
"My name is John and I prefer Python for backend work."
)
# Returns: [
# {"type": "entity", "name": "John", "entity_type": "person"},
# {"type": "preference", "content": "prefers Python for backend work"}
# ]
```
"""
# Patterns for fast extraction (no LLM needed)
PATTERNS = {
# Name patterns
"name": [
r"(?:my name is|i'm|i am|call me)\s+([A-Z][a-z]+)",
r"(?:name|Name):\s*([A-Z][a-z]+)",
],
# Preference patterns
"preference": [
r"i (?:prefer|like|love|enjoy|use)\s+(.+?)(?:\.|$)",
r"(?:my favorite|i always use)\s+(.+?)(?:\.|$)",
],
# Role/job patterns
"role": [
r"i (?:am|work as|'m)\s+(?:a|an)\s+([a-z\s]+?)(?:\.|,|$)",
r"(?:my job|my role|i do)\s+(?:is|as)?\s*(.+?)(?:\.|$)",
],
# Location patterns
"location": [
r"i (?:live|work|am based|am located)\s+(?:in|at)\s+([A-Z][a-z\s]+)",
r"(?:from|based in)\s+([A-Z][a-z\s,]+)",
],
# Project/task patterns
"project": [
r"(?:working on|building|developing|creating)\s+(?:a|an|the)?\s*(.+?)(?:\.|$)",
r"(?:my project|the project)\s+(?:is|called)?\s*(.+?)(?:\.|$)",
],
# Technology preferences
"technology": [
r"(?:using|use|prefer)\s+(Python|JavaScript|TypeScript|Go|Rust|Java|C\+\+|Ruby|PHP)",
r"(?:framework|library|tool)(?:s)?\s+(?:like|such as|including)\s+(.+?)(?:\.|$)",
],
}
# Importance scores by type
IMPORTANCE_SCORES = {
"name": 0.95,
"preference": 0.7,
"role": 0.85,
"location": 0.6,
"project": 0.75,
"technology": 0.7,
}
def __init__(
self,
min_importance: float = 0.5,
use_llm: bool = False,
llm_func: Optional[Callable[[str], str]] = None,
verbose: int = 0
):
"""
Initialize AutoMemoryExtractor.
Args:
min_importance: Minimum importance score to extract (0.0-1.0)
use_llm: Whether to use LLM for enhanced extraction
llm_func: LLM function for extraction (if use_llm=True)
verbose: Verbosity level
"""
self.min_importance = min_importance
self.use_llm = use_llm
self.llm_func = llm_func
self.verbose = verbose
# Compile patterns for performance
self._compiled_patterns = {}
for category, patterns in self.PATTERNS.items():
self._compiled_patterns[category] = [
re.compile(p, re.IGNORECASE) for p in patterns
]
def extract(self, text: str) -> List[Dict[str, Any]]:
"""
Extract memorable content from text.
Args:
text: Text to extract from (conversation, message, etc.)
Returns:
List of extracted memories with type, content, and importance
"""
memories = []
# Fast pattern-based extraction
for category, patterns in self._compiled_patterns.items():
importance = self.IMPORTANCE_SCORES.get(category, 0.5)
if importance < self.min_importance:
continue
for pattern in patterns:
matches = pattern.findall(text)
for match in matches:
if isinstance(match, tuple):
match = match[0]
match = match.strip()
if len(match) < 2 or len(match) > 200:
continue
memory = {
"type": category,
"content": match,
"importance": importance,
"source": "pattern"
}
# Avoid duplicates
if not any(m["content"].lower() == match.lower() for m in memories):
memories.append(memory)
# Optional LLM enhancement
if self.use_llm and self.llm_func and len(text) > 50:
llm_memories = self._extract_with_llm(text)
for mem in llm_memories:
if mem.get("importance", 0) >= self.min_importance:
if not any(m["content"].lower() == mem["content"].lower() for m in memories):
memories.append(mem)
return memories
def _extract_with_llm(self, text: str) -> List[Dict[str, Any]]:
"""Extract memories using LLM for better accuracy."""
if not self.llm_func:
return []
prompt = f"""Extract important facts from this text that should be remembered.
Return a JSON array of objects with: type (name/preference/role/location/project/technology), content, importance (0.0-1.0).
Only extract clear, specific facts. Be concise.
Text: {text[:1500]}
JSON array (no markdown, just the array):"""
try:
response = self.llm_func(prompt)
# Parse JSON from response
import json
# Find JSON array in response
start = response.find("[")
end = response.rfind("]") + 1
if start >= 0 and end > start:
return json.loads(response[start:end])
except Exception as e:
if self.verbose:
logger.warning(f"LLM extraction failed: {e}")
return []
def should_remember(self, text: str) -> bool:
"""
Quick check if text likely contains memorable content.
Useful for filtering before full extraction.
Args:
text: Text to check
Returns:
True if text likely contains memorable content
"""
# Quick keyword check
keywords = [
"my name", "i am", "i'm", "i prefer", "i like", "i use",
"i work", "i live", "working on", "building", "my project",
"favorite", "always use"
]
text_lower = text.lower()
return any(kw in text_lower for kw in keywords)
class AutoMemory:
"""
Wrapper that adds auto-generation capabilities to FileMemory.
Intercepts interactions and automatically extracts/stores memories.
Designed to be lightweight - only processes when enabled.
Example:
```python
from praisonaiagents.memory import FileMemory
base_memory = FileMemory(user_id="user123")
auto_memory = AutoMemory(base_memory, enabled=True)
# Process conversation and auto-store memories
auto_memory.process_interaction(
user_message="My name is John and I prefer Python",
assistant_response="Nice to meet you, John!"
)
```
"""
def __init__(
self,
memory: "FileMemory",
enabled: bool = True,
extractor: Optional[AutoMemoryExtractor] = None,
min_importance: float = 0.6,
verbose: int = 0
):
"""
Initialize AutoMemory wrapper.
Args:
memory: Base FileMemory instance
enabled: Whether auto-generation is enabled
extractor: Custom extractor (or use default)
min_importance: Minimum importance to auto-store
verbose: Verbosity level
"""
self.memory = memory
self.enabled = enabled
self.min_importance = min_importance
self.verbose = verbose
self.extractor = extractor or AutoMemoryExtractor(
min_importance=min_importance,
verbose=verbose
)
# Track what we've already processed to avoid duplicates
self._processed_hashes: set = set()
def process_interaction(
self,
user_message: str,
assistant_response: Optional[str] = None,
store: bool = True
) -> List[Dict[str, Any]]:
"""
Process an interaction and extract memories.
Args:
user_message: User's message
assistant_response: Optional assistant response
store: Whether to store extracted memories
Returns:
List of extracted memories
"""
if not self.enabled:
return []
# Combine messages for extraction
text = user_message
if assistant_response:
text += "\n" + assistant_response
# Check if already processed
import hashlib
text_hash = hashlib.md5(text.encode()).hexdigest()[:16]
if text_hash in self._processed_hashes:
return []
self._processed_hashes.add(text_hash)
# Quick filter
if not self.extractor.should_remember(text):
return []
# Extract memories
memories = self.extractor.extract(text)
if store and memories:
self._store_memories(memories)
return memories
def _store_memories(self, memories: List[Dict[str, Any]]):
"""Store extracted memories in the base memory."""
for mem in memories:
mem_type = mem.get("type", "")
content = mem.get("content", "")
importance = mem.get("importance", 0.5)
if not content:
continue
# Store based on type
if mem_type == "name":
# Store as entity
self.memory.add_entity(
name=content,
entity_type="person",
attributes={"relationship": "user"}
)
elif mem_type in ("role", "location"):
# Store as entity attribute
self.memory.add_long_term(
f"User's {mem_type}: {content}",
importance=importance
)
else:
# Store as long-term memory
self.memory.add_long_term(content, importance=importance)
if self.verbose:
logger.info(f"Auto-stored memory: {mem_type} - {content[:50]}...")
def enable(self):
"""Enable auto-generation."""
self.enabled = True
def disable(self):
"""Disable auto-generation."""
self.enabled = False
def set_llm(self, llm_func: Callable[[str], str]):
"""Set LLM function for enhanced extraction."""
self.extractor.use_llm = True
self.extractor.llm_func = llm_func
def create_auto_memory(
memory: "FileMemory",
enabled: bool = True,
**kwargs
) -> AutoMemory:
"""
Create an AutoMemory wrapper.
Args:
memory: Base FileMemory instance
enabled: Whether auto-generation is enabled
**kwargs: Additional configuration
Returns:
AutoMemory instance
"""
return AutoMemory(memory, enabled=enabled, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,417 @@
"""
Cascade Hooks System for PraisonAI Agents.
Provides event hooks similar to Windsurf's Cascade Hooks,
allowing custom actions before/after agent operations.
Features:
- Pre/post hooks for read, write, command, prompt operations
- JSON configuration (.praison/hooks.json)
- Script execution with timeout
- Exit code handling
- Lazy loading for performance
Configuration (.praison/hooks.json):
{
"hooks": {
"pre_write_code": "./scripts/lint.sh",
"post_write_code": "./scripts/format.sh",
"pre_run_command": "./scripts/validate.sh",
"post_user_prompt": "./scripts/log.sh"
},
"timeout": 30,
"enabled": true
}
Hook Events:
- pre_read_code: Before reading a file
- post_read_code: After reading a file
- pre_write_code: Before writing to a file
- post_write_code: After writing to a file
- pre_run_command: Before running a terminal command
- post_run_command: After running a terminal command
- pre_user_prompt: Before processing user prompt
- post_user_prompt: After processing user prompt
"""
import os
import json
import logging
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable, Literal
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Hook event types
HookEvent = Literal[
"pre_read_code",
"post_read_code",
"pre_write_code",
"post_write_code",
"pre_run_command",
"post_run_command",
"pre_user_prompt",
"post_user_prompt",
"pre_mcp_tool_use",
"post_mcp_tool_use"
]
@dataclass
class HookResult:
"""Result of a hook execution."""
success: bool
exit_code: int = 0
stdout: str = ""
stderr: str = ""
blocked: bool = False # If True, the operation should be blocked
modified_input: Optional[str] = None # Modified input if applicable
@dataclass
class HookConfig:
"""Configuration for a single hook."""
event: HookEvent
command: str
timeout: int = 30
enabled: bool = True
block_on_failure: bool = False
pass_input: bool = True
class HooksManager:
"""
Manages hook discovery, loading, and execution.
Hooks are configured in .praison/hooks.json and can execute
scripts before/after agent operations.
Example:
```python
hooks = HooksManager(workspace_path="/path/to/project")
# Execute pre-write hook
result = hooks.execute(
"pre_write_code",
context={"file_path": "main.py", "content": "..."}
)
if result.blocked:
print("Write blocked by hook")
```
"""
CONFIG_FILE = ".praison/hooks.json"
def __init__(
self,
workspace_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize HooksManager.
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
self._hooks: Dict[HookEvent, List[HookConfig]] = {}
self._global_timeout = 30
self._enabled = True
self._loaded = False
# Python callable hooks (for programmatic use)
self._callable_hooks: Dict[HookEvent, List[Callable]] = {}
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _ensure_loaded(self):
"""Lazy load hooks on first access."""
if not self._loaded:
self._load_config()
self._loaded = True
def _load_config(self):
"""Load hooks configuration from file."""
config_path = self.workspace_path / self.CONFIG_FILE.replace("/", os.sep)
if not config_path.exists():
return
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
self._enabled = config.get("enabled", True)
self._global_timeout = config.get("timeout", 30)
hooks_config = config.get("hooks", {})
for event, command in hooks_config.items():
if event not in self._hooks:
self._hooks[event] = []
if isinstance(command, str):
# Simple command string
self._hooks[event].append(HookConfig(
event=event,
command=command,
timeout=self._global_timeout
))
elif isinstance(command, dict):
# Detailed config
self._hooks[event].append(HookConfig(
event=event,
command=command.get("command", ""),
timeout=command.get("timeout", self._global_timeout),
enabled=command.get("enabled", True),
block_on_failure=command.get("block_on_failure", False),
pass_input=command.get("pass_input", True)
))
elif isinstance(command, list):
# Multiple commands for same event
for cmd in command:
if isinstance(cmd, str):
self._hooks[event].append(HookConfig(
event=event,
command=cmd,
timeout=self._global_timeout
))
self._log(f"Loaded {sum(len(h) for h in self._hooks.values())} hooks")
except Exception as e:
self._log(f"Error loading hooks config: {e}", logging.WARNING)
def reload(self):
"""Reload hooks configuration."""
self._hooks = {}
self._loaded = False
self._ensure_loaded()
def register(
self,
event: HookEvent,
handler: Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
):
"""
Register a Python callable as a hook.
Args:
event: Hook event type
handler: Callable that receives context dict and optionally returns modified context
"""
if event not in self._callable_hooks:
self._callable_hooks[event] = []
self._callable_hooks[event].append(handler)
def unregister(self, event: HookEvent, handler: Callable):
"""Unregister a callable hook."""
if event in self._callable_hooks:
self._callable_hooks[event] = [
h for h in self._callable_hooks[event] if h != handler
]
def execute(
self,
event: HookEvent,
context: Optional[Dict[str, Any]] = None
) -> HookResult:
"""
Execute hooks for an event.
Args:
event: Hook event type
context: Context data to pass to hooks
Returns:
HookResult with execution status
"""
self._ensure_loaded()
if not self._enabled:
return HookResult(success=True)
context = context or {}
combined_result = HookResult(success=True)
# Execute callable hooks first
if event in self._callable_hooks:
for handler in self._callable_hooks[event]:
try:
result = handler(context)
if isinstance(result, dict):
context.update(result)
except Exception as e:
self._log(f"Callable hook error for {event}: {e}", logging.WARNING)
combined_result.success = False
# Execute script hooks
if event in self._hooks:
for hook in self._hooks[event]:
if not hook.enabled:
continue
result = self._execute_script(hook, context)
if not result.success:
combined_result.success = False
combined_result.stderr += result.stderr
if hook.block_on_failure:
combined_result.blocked = True
break
combined_result.stdout += result.stdout
if result.modified_input:
combined_result.modified_input = result.modified_input
return combined_result
def _execute_script(
self,
hook: HookConfig,
context: Dict[str, Any]
) -> HookResult:
"""Execute a script hook."""
try:
# Prepare environment
env = os.environ.copy()
# Pass context as environment variables
if hook.pass_input:
for key, value in context.items():
if isinstance(value, (str, int, float, bool)):
env[f"PRAISON_HOOK_{key.upper()}"] = str(value)
# Also pass as JSON
env["PRAISON_HOOK_CONTEXT"] = json.dumps(context)
# Resolve command path
command = hook.command
if not os.path.isabs(command) and not command.startswith("./"):
command = str(self.workspace_path / command)
# Execute
result = subprocess.run(
command,
shell=True,
cwd=str(self.workspace_path),
env=env,
capture_output=True,
text=True,
timeout=hook.timeout
)
success = result.returncode == 0
# Check for special exit codes
blocked = result.returncode == 1 and hook.block_on_failure
# Check stdout for modified input
modified_input = None
if result.stdout.startswith("MODIFIED:"):
modified_input = result.stdout[9:].strip()
self._log(f"Hook {hook.event} executed: exit={result.returncode}")
return HookResult(
success=success,
exit_code=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
blocked=blocked,
modified_input=modified_input
)
except subprocess.TimeoutExpired:
self._log(f"Hook {hook.event} timed out after {hook.timeout}s", logging.WARNING)
return HookResult(
success=False,
exit_code=-1,
stderr=f"Timeout after {hook.timeout}s"
)
except Exception as e:
self._log(f"Hook {hook.event} error: {e}", logging.WARNING)
return HookResult(
success=False,
exit_code=-1,
stderr=str(e)
)
def has_hooks(self, event: HookEvent) -> bool:
"""Check if any hooks are registered for an event."""
self._ensure_loaded()
return (
event in self._hooks and len(self._hooks[event]) > 0 or
event in self._callable_hooks and len(self._callable_hooks[event]) > 0
)
def get_stats(self) -> Dict[str, Any]:
"""Get hooks statistics."""
self._ensure_loaded()
script_hooks = sum(len(h) for h in self._hooks.values())
callable_hooks = sum(len(h) for h in self._callable_hooks.values())
return {
"enabled": self._enabled,
"script_hooks": script_hooks,
"callable_hooks": callable_hooks,
"total_hooks": script_hooks + callable_hooks,
"events": list(set(list(self._hooks.keys()) + list(self._callable_hooks.keys())))
}
def create_config(
self,
hooks: Optional[Dict[str, str]] = None,
timeout: int = 30,
enabled: bool = True
):
"""
Create a hooks configuration file.
Args:
hooks: Dict mapping event names to commands
timeout: Global timeout in seconds
enabled: Whether hooks are enabled
"""
config_dir = self.workspace_path / ".praison"
config_dir.mkdir(parents=True, exist_ok=True)
config = {
"enabled": enabled,
"timeout": timeout,
"hooks": hooks or {}
}
config_path = config_dir / "hooks.json"
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
self._log(f"Created hooks config at {config_path}")
self.reload()
def create_hooks_manager(
workspace_path: Optional[str] = None,
**kwargs
) -> HooksManager:
"""
Create a HooksManager instance.
Args:
workspace_path: Path to workspace
**kwargs: Additional configuration
Returns:
HooksManager instance
"""
return HooksManager(workspace_path=workspace_path, **kwargs)
@@ -0,0 +1,819 @@
"""
Rules Manager for PraisonAI Agents.
Provides persistent rules/instructions support similar to:
- Cursor (.cursor/rules/*.mdc)
- Windsurf (.windsurf/rules/)
- Claude Code (CLAUDE.md, .claude/rules/)
- Codex CLI (AGENTS.md)
- Gemini CLI (GEMINI.md)
Features:
- Auto-discovery of root instruction files (CLAUDE.md, AGENTS.md, etc.)
- Git root discovery for monorepo support
- @Import syntax for including other files
- Local override files (CLAUDE.local.md)
- Multiple activation modes (always, glob, manual, ai_decision)
- Character limits for context window management
- Symlink support for shared rules
Storage Structure:
.praison/rules/
├── global.md # Always applied (global)
├── python.md # Glob: **/*.py
├── testing.md # Glob: **/*.test.*
└── security.md # Activation: manual (@security)
Rule File Format (YAML frontmatter + Markdown):
---
description: Python coding guidelines
globs: ["**/*.py", "**/*.pyx"]
activation: always # always, glob, manual, ai_decision
priority: 10
---
# Python Guidelines
- Use type hints
- Follow PEP 8
"""
import os
import re
import fnmatch
import logging
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Literal, Callable
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class Rule:
"""A single rule with metadata and content."""
name: str
content: str
description: str = ""
globs: List[str] = field(default_factory=list)
activation: Literal["always", "glob", "manual", "ai_decision"] = "always"
priority: int = 0
file_path: Optional[str] = None
def matches_file(self, file_path: str) -> bool:
"""Check if this rule matches a given file path."""
if self.activation == "always":
return True
if self.activation == "manual":
return False # Only activated via @mention
if self.activation == "glob" and self.globs:
for pattern in self.globs:
if fnmatch.fnmatch(file_path, pattern):
return True
# Also try with ** expansion
if "**" in pattern:
# Convert ** to regex for recursive matching
regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*")
if re.match(regex_pattern, file_path):
return True
return False
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"content": self.content,
"description": self.description,
"globs": self.globs,
"activation": self.activation,
"priority": self.priority,
"file_path": self.file_path
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Rule':
return cls(**data)
class RulesManager:
"""
Manages rules/instructions for AI agents.
Supports multiple rule sources:
- Global rules (~/.praison/rules/)
- Workspace rules (.praison/rules/)
- Subdirectory rules (subdir/.praison/rules/)
- Root instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, .windsurfrules)
Example:
```python
rules = RulesManager(workspace_path="/path/to/project")
# Get all active rules
active_rules = rules.get_active_rules()
# Get rules for specific file
python_rules = rules.get_rules_for_file("src/main.py")
# Get manually invoked rule
security_rule = rules.get_rule_by_name("security")
# Build context string for LLM
context = rules.build_rules_context(file_path="src/main.py")
```
"""
RULES_DIR_NAME = ".praison/rules"
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
ROOT_INSTRUCTION_FILES = [
"CLAUDE.md", # Claude Code memory file
"CLAUDE.local.md", # Claude Code local overrides (gitignored)
"AGENTS.md", # OpenAI Codex CLI instructions
"GEMINI.md", # Gemini CLI memory file
".cursorrules", # Cursor legacy rules (deprecated but supported)
".windsurfrules", # Windsurf legacy rules
"PRAISON.md", # PraisonAI native instructions
"PRAISON.local.md", # PraisonAI local overrides (gitignored)
]
# Additional rules directories to discover (like Claude, Windsurf)
ADDITIONAL_RULES_DIRS = [
".claude/rules", # Claude Code modular rules
".windsurf/rules", # Windsurf rules
".cursor/rules", # Cursor rules
]
# Maximum characters per rule (like Windsurf's 12000 limit)
MAX_RULE_CHARS = 12000
# Import pattern for @path/to/file syntax
IMPORT_PATTERN = re.compile(r'(?<!`)@([\w./-]+)(?!`)')
def __init__(
self,
workspace_path: Optional[str] = None,
global_rules_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize RulesManager.
Args:
workspace_path: Path to workspace/project root
global_rules_path: Path to global rules (default: ~/.praison/rules)
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praison" / "rules"
self.verbose = verbose
self._rules: Dict[str, Rule] = {}
self._load_all_rules()
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _find_git_root(self, start_path: Optional[Path] = None) -> Optional[Path]:
"""
Find the git repository root from a starting path.
Uses pure Python (no gitpython dependency) for performance.
Falls back to subprocess if needed.
Args:
start_path: Starting path (defaults to workspace_path)
Returns:
Path to git root, or None if not in a git repo
"""
start = start_path or self.workspace_path
# Method 1: Walk up looking for .git directory (fastest, no subprocess)
current = Path(start).resolve()
while current != current.parent:
git_dir = current / ".git"
if git_dir.exists():
return current
current = current.parent
# Method 2: Fallback to git command (handles edge cases like worktrees)
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=str(start),
capture_output=True,
text=True,
timeout=2 # Prevent hanging
)
if result.returncode == 0:
return Path(result.stdout.strip())
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
return None
def _process_imports(self, content: str, base_path: Path, depth: int = 0) -> str:
"""
Process @import syntax in rule content.
Supports:
- @path/to/file - Import relative to workspace
- @~/path/to/file - Import from home directory
- @./path/to/file - Import relative to current file
Args:
content: Rule content with potential @imports
base_path: Base path for relative imports
depth: Current recursion depth (max 5 to prevent loops)
Returns:
Content with imports resolved
"""
if depth > 5:
self._log("Import depth exceeded (max 5), stopping", logging.WARNING)
return content
def replace_import(match):
import_path = match.group(1)
# Skip if it looks like a mention (@username) or package (@org/pkg)
if "/" not in import_path and "." not in import_path:
return match.group(0)
# Resolve path
if import_path.startswith("~/"):
file_path = Path.home() / import_path[2:]
elif import_path.startswith("./"):
file_path = base_path / import_path[2:]
else:
file_path = self.workspace_path / import_path
# Try with and without .md extension
if not file_path.exists() and not file_path.suffix:
file_path = file_path.with_suffix(".md")
if file_path.exists() and file_path.is_file():
try:
imported_content = file_path.read_text(encoding="utf-8")
# Recursively process imports in imported content
imported_content = self._process_imports(
imported_content,
file_path.parent,
depth + 1
)
return imported_content
except Exception as e:
self._log(f"Failed to import {file_path}: {e}", logging.WARNING)
return match.group(0)
else:
# Not a file import, leave as-is (might be @mention)
return match.group(0)
return self.IMPORT_PATTERN.sub(replace_import, content)
def _truncate_rule_content(self, content: str) -> str:
"""Truncate rule content to MAX_RULE_CHARS if needed."""
if len(content) <= self.MAX_RULE_CHARS:
return content
truncated = content[:self.MAX_RULE_CHARS - 50]
return truncated + "\n\n... (truncated, exceeded 12000 char limit)"
def _parse_frontmatter(self, content: str) -> tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content."""
frontmatter = {}
body = content
# Check for YAML frontmatter (--- ... ---)
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
yaml_content = parts[1].strip()
body = parts[2].strip()
# Simple YAML parsing (avoid dependency)
for line in yaml_content.split("\n"):
line = line.strip()
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
# Handle lists
if value.startswith("[") and value.endswith("]"):
# Parse simple list: ["*.py", "*.pyx"]
value = [v.strip().strip('"\'') for v in value[1:-1].split(",") if v.strip()]
# Handle booleans
elif value.lower() in ("true", "false"):
value = value.lower() == "true"
# Handle numbers
elif value.isdigit():
value = int(value)
# Handle quoted strings
elif value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
frontmatter[key] = value
return frontmatter, body
def _load_rule_file(self, file_path: Path) -> Optional[Rule]:
"""Load a single rule file with @import processing and truncation."""
try:
content = file_path.read_text(encoding="utf-8")
frontmatter, body = self._parse_frontmatter(content)
# Process @imports in the body
body = self._process_imports(body, file_path.parent)
# Truncate if exceeds limit
body = self._truncate_rule_content(body)
# Extract rule name from filename
name = file_path.stem
# Build Rule object
rule = Rule(
name=name,
content=body,
description=frontmatter.get("description", ""),
globs=frontmatter.get("globs", []),
activation=frontmatter.get("activation", "always"),
priority=frontmatter.get("priority", 0),
file_path=str(file_path)
)
self._log(f"Loaded rule '{name}' from {file_path}")
return rule
except Exception as e:
self._log(f"Error loading rule file {file_path}: {e}", logging.WARNING)
return None
def _discover_rules_in_dir(self, rules_dir: Path) -> List[Rule]:
"""Discover all rule files in a directory."""
rules = []
if not rules_dir.exists():
return rules
for ext in self.SUPPORTED_EXTENSIONS:
for file_path in rules_dir.glob(f"*{ext}"):
rule = self._load_rule_file(file_path)
if rule:
rules.append(rule)
return rules
def _load_root_instruction_file(self, file_path: Path) -> Optional[Rule]:
"""Load a root instruction file (CLAUDE.md, AGENTS.md, etc.) with @import processing."""
try:
if not file_path.exists():
return None
# Handle symlinks (like Claude Code supports)
if file_path.is_symlink():
file_path = file_path.resolve()
if not file_path.exists():
return None
content = file_path.read_text(encoding="utf-8")
frontmatter, body = self._parse_frontmatter(content)
# Process @imports in the body
body_content = body if body else content
body_content = self._process_imports(body_content, file_path.parent)
# Truncate if exceeds limit
body_content = self._truncate_rule_content(body_content)
# Use filename (without extension) as rule name
name = file_path.stem.lower().replace(".", "_")
# Determine description based on file type
descriptions = {
"claude": "Claude Code memory instructions",
"claude_local": "Claude Code local overrides",
"agents": "OpenAI Codex CLI instructions",
"gemini": "Gemini CLI memory instructions",
"cursorrules": "Cursor IDE rules (legacy)",
"windsurfrules": "Windsurf IDE rules (legacy)",
"praison": "PraisonAI native instructions",
"praison_local": "PraisonAI local overrides",
}
description = descriptions.get(name, f"Instructions from {file_path.name}")
# Local files get higher priority (override main files)
base_priority = 600 if "_local" in name else 500
rule = Rule(
name=name,
content=body_content,
description=frontmatter.get("description", description),
globs=frontmatter.get("globs", []),
activation=frontmatter.get("activation", "always"),
priority=frontmatter.get("priority", base_priority),
file_path=str(file_path)
)
self._log(f"Loaded root instruction file '{file_path.name}'")
return rule
except Exception as e:
self._log(f"Error loading root instruction file {file_path}: {e}", logging.WARNING)
return None
def _load_all_rules(self):
"""Load rules from all sources including git root and additional directories."""
self._rules = {}
# Find git root for monorepo support
git_root = self._find_git_root()
# 1. Load global rules (lowest priority)
global_rules = self._discover_rules_in_dir(self.global_rules_path)
for rule in global_rules:
rule.priority = rule.priority - 1000 # Lower priority for global
self._rules[f"global:{rule.name}"] = rule
# 2. Load root instruction files from git root (if different from workspace)
if git_root and git_root != self.workspace_path:
for filename in self.ROOT_INSTRUCTION_FILES:
file_path = git_root / filename
rule = self._load_root_instruction_file(file_path)
if rule:
rule.priority = rule.priority - 100 # Slightly lower than workspace
self._rules[f"gitroot:{rule.name}"] = rule
# 3. Load root instruction files from workspace (CLAUDE.md, AGENTS.md, etc.)
for filename in self.ROOT_INSTRUCTION_FILES:
file_path = self.workspace_path / filename
rule = self._load_root_instruction_file(file_path)
if rule:
self._rules[f"root:{rule.name}"] = rule
# 4. Load workspace rules from .praison/rules/
workspace_rules_dir = self.workspace_path / self.RULES_DIR_NAME.replace("/", os.sep)
workspace_rules = self._discover_rules_in_dir(workspace_rules_dir)
for rule in workspace_rules:
self._rules[f"workspace:{rule.name}"] = rule
# 5. Load additional rules directories (.claude/rules, .windsurf/rules, .cursor/rules)
for rules_dir_name in self.ADDITIONAL_RULES_DIRS:
# Check workspace
rules_dir = self.workspace_path / rules_dir_name.replace("/", os.sep)
additional_rules = self._discover_rules_in_dir(rules_dir)
for rule in additional_rules:
rule.priority = rule.priority + 50 # Slightly higher priority
dir_prefix = rules_dir_name.split("/")[0].strip(".")
self._rules[f"{dir_prefix}:{rule.name}"] = rule
# Check git root if different
if git_root and git_root != self.workspace_path:
rules_dir = git_root / rules_dir_name.replace("/", os.sep)
additional_rules = self._discover_rules_in_dir(rules_dir)
for rule in additional_rules:
dir_prefix = rules_dir_name.split("/")[0].strip(".")
self._rules[f"gitroot_{dir_prefix}:{rule.name}"] = rule
# 6. Load subdirectory rules (walk up from cwd to workspace/git root)
stop_at = git_root if git_root else self.workspace_path
current = Path.cwd()
while current != stop_at and current != current.parent:
# Check .praison/rules/
subdir_rules_dir = current / self.RULES_DIR_NAME.replace("/", os.sep)
subdir_rules = self._discover_rules_in_dir(subdir_rules_dir)
for rule in subdir_rules:
rule.priority = rule.priority + 100 # Higher priority for closer dirs
self._rules[f"subdir:{rule.name}"] = rule
# Check additional rules directories in subdirs
for rules_dir_name in self.ADDITIONAL_RULES_DIRS:
subdir_rules_dir = current / rules_dir_name.replace("/", os.sep)
subdir_rules = self._discover_rules_in_dir(subdir_rules_dir)
for rule in subdir_rules:
rule.priority = rule.priority + 150 # Even higher for subdir additional
dir_prefix = rules_dir_name.split("/")[0].strip(".")
self._rules[f"subdir_{dir_prefix}:{rule.name}"] = rule
current = current.parent
self._log(f"Loaded {len(self._rules)} rules total")
def reload(self):
"""Reload all rules from disk."""
self._load_all_rules()
def get_all_rules(self) -> List[Rule]:
"""Get all loaded rules, sorted by priority."""
rules = list(self._rules.values())
rules.sort(key=lambda r: r.priority, reverse=True)
return rules
def get_active_rules(self, file_path: Optional[str] = None) -> List[Rule]:
"""
Get rules that are currently active.
Args:
file_path: Optional file path to filter glob-based rules
Returns:
List of active rules sorted by priority
"""
active = []
for rule in self._rules.values():
if rule.activation == "always":
active.append(rule)
elif rule.activation == "glob" and file_path:
if rule.matches_file(file_path):
active.append(rule)
elif rule.activation == "ai_decision":
# AI will decide - include for context
active.append(rule)
# Skip "manual" - only activated via @mention
active.sort(key=lambda r: r.priority, reverse=True)
return active
def get_rules_for_file(self, file_path: str) -> List[Rule]:
"""Get rules that apply to a specific file."""
return self.get_active_rules(file_path=file_path)
def get_rule_by_name(self, name: str) -> Optional[Rule]:
"""Get a rule by name (for manual @mention invocation)."""
# Check all scopes
for scope in ["subdir", "workspace", "global"]:
key = f"{scope}:{name}"
if key in self._rules:
return self._rules[key]
# Also check without scope prefix
for rule in self._rules.values():
if rule.name == name:
return rule
return None
def get_manual_rules(self) -> List[Rule]:
"""Get all rules that require manual activation."""
return [r for r in self._rules.values() if r.activation == "manual"]
def build_rules_context(
self,
file_path: Optional[str] = None,
include_manual: Optional[List[str]] = None,
max_chars: int = 10000
) -> str:
"""
Build context string from active rules for LLM.
Args:
file_path: Optional file path for glob matching
include_manual: List of manual rule names to include
max_chars: Maximum characters in output
Returns:
Formatted rules context string
"""
parts = []
total_chars = 0
# Get active rules
active_rules = self.get_active_rules(file_path=file_path)
# Add manually invoked rules
if include_manual:
for name in include_manual:
rule = self.get_rule_by_name(name)
if rule and rule not in active_rules:
active_rules.append(rule)
# Sort by priority
active_rules.sort(key=lambda r: r.priority, reverse=True)
for rule in active_rules:
if total_chars >= max_chars:
break
rule_text = rule.content.strip()
if not rule_text:
continue
# Add rule with header
if rule.description:
header = f"## {rule.name}: {rule.description}"
else:
header = f"## {rule.name}"
section = f"{header}\n{rule_text}\n"
if total_chars + len(section) <= max_chars:
parts.append(section)
total_chars += len(section)
else:
# Truncate last rule
remaining = max_chars - total_chars
if remaining > 100:
parts.append(section[:remaining] + "\n... (truncated)")
break
return "\n".join(parts)
def create_rule(
self,
name: str,
content: str,
description: str = "",
globs: Optional[List[str]] = None,
activation: Literal["always", "glob", "manual", "ai_decision"] = "always",
priority: int = 0,
scope: Literal["global", "workspace"] = "workspace"
) -> Rule:
"""
Create a new rule file.
Args:
name: Rule name (used as filename)
content: Rule content (markdown)
description: Short description
globs: Glob patterns for activation
activation: Activation mode
priority: Priority (higher = applied first)
scope: Where to save (global or workspace)
Returns:
Created Rule object
"""
# Determine save path
if scope == "global":
rules_dir = self.global_rules_path
else:
rules_dir = self.workspace_path / self.RULES_DIR_NAME.replace("/", os.sep)
rules_dir.mkdir(parents=True, exist_ok=True)
file_path = rules_dir / f"{name}.md"
# Build frontmatter
frontmatter_lines = ["---"]
if description:
frontmatter_lines.append(f'description: "{description}"')
if globs:
globs_str = ", ".join(f'"{g}"' for g in globs)
frontmatter_lines.append(f"globs: [{globs_str}]")
frontmatter_lines.append(f"activation: {activation}")
if priority != 0:
frontmatter_lines.append(f"priority: {priority}")
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 rule
rule = Rule(
name=name,
content=content,
description=description,
globs=globs or [],
activation=activation,
priority=priority,
file_path=str(file_path)
)
self._rules[f"{scope}:{name}"] = rule
self._log(f"Created rule '{name}' at {file_path}")
return rule
def delete_rule(self, name: str, scope: Optional[str] = None) -> bool:
"""
Delete a rule file.
Args:
name: Rule name
scope: Scope to delete from (global, workspace, or None for any)
Returns:
True if deleted, False if not found
"""
# Find the rule
rule = None
key = None
if scope:
key = f"{scope}:{name}"
rule = self._rules.get(key)
else:
for s in ["subdir", "workspace", "global"]:
key = f"{s}:{name}"
if key in self._rules:
rule = self._rules[key]
break
if not rule or not rule.file_path:
return False
# Delete file
try:
Path(rule.file_path).unlink()
del self._rules[key]
self._log(f"Deleted rule '{name}'")
return True
except Exception as e:
self._log(f"Error deleting rule '{name}': {e}", logging.ERROR)
return False
def evaluate_ai_decision(
self,
rule: Rule,
context: str,
llm_func: Optional[Callable[[str], str]] = None
) -> bool:
"""
Evaluate if an ai_decision rule should be applied.
Uses the rule's description to determine relevance to current context.
Args:
rule: Rule with activation="ai_decision"
context: Current conversation/task context
llm_func: Optional LLM function for evaluation (if None, returns True)
Returns:
True if rule should be applied, False otherwise
"""
if rule.activation != "ai_decision":
return rule.activation == "always"
if not llm_func:
# Without LLM, include ai_decision rules by default
return True
# Use LLM to decide
prompt = f"""Determine if the following rule should be applied to the current context.
Rule Name: {rule.name}
Rule Description: {rule.description}
Current Context:
{context[:1000]}
Should this rule be applied? Answer only "yes" or "no"."""
try:
response = llm_func(prompt).strip().lower()
return response.startswith("yes")
except Exception as e:
self._log(f"Error evaluating ai_decision for rule '{rule.name}': {e}", logging.WARNING)
return True # Default to including the rule
def get_stats(self) -> Dict[str, Any]:
"""Get rules statistics."""
rules = list(self._rules.values())
# Count rules by source
source_counts = {}
for key in self._rules.keys():
source = key.split(":")[0]
source_counts[source] = source_counts.get(source, 0) + 1
return {
"total_rules": len(rules),
"always_rules": len([r for r in rules if r.activation == "always"]),
"glob_rules": len([r for r in rules if r.activation == "glob"]),
"manual_rules": len([r for r in rules if r.activation == "manual"]),
"ai_decision_rules": len([r for r in rules if r.activation == "ai_decision"]),
"global_rules": source_counts.get("global", 0),
"root_rules": source_counts.get("root", 0),
"gitroot_rules": source_counts.get("gitroot", 0),
"workspace_rules": source_counts.get("workspace", 0),
"subdir_rules": source_counts.get("subdir", 0),
"claude_rules": source_counts.get("claude", 0),
"windsurf_rules": source_counts.get("windsurf", 0),
"cursor_rules": source_counts.get("cursor", 0),
"sources": source_counts
}
def create_rules_manager(
workspace_path: Optional[str] = None,
**kwargs
) -> RulesManager:
"""
Create a RulesManager instance.
Args:
workspace_path: Path to workspace
**kwargs: Additional configuration
Returns:
RulesManager instance
"""
return RulesManager(workspace_path=workspace_path, **kwargs)
@@ -0,0 +1,514 @@
"""
Workflows System for PraisonAI Agents.
Provides reusable multi-step workflows similar to Windsurf's Workflows,
allowing users to define and execute complex task sequences.
Features:
- YAML/Markdown workflow definitions
- Auto-discovery from .praison/workflows/
- Step-by-step execution with context passing
- Variable substitution
- Conditional steps
- Lazy loading for performance
Storage Structure:
.praison/workflows/
├── deploy.md # Deployment workflow
├── test.md # Testing workflow
└── review.md # Code review workflow
"""
import os
import re
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable, Literal
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class WorkflowStep:
"""A single step in a workflow."""
name: str
description: str = ""
action: str = "" # The action/prompt to execute
condition: Optional[str] = None # Optional condition for execution
on_error: Literal["stop", "continue", "retry"] = "stop"
max_retries: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"action": self.action,
"condition": self.condition,
"on_error": self.on_error,
"max_retries": self.max_retries
}
@dataclass
class Workflow:
"""A complete workflow with multiple steps."""
name: str
description: str = ""
steps: List[WorkflowStep] = field(default_factory=list)
variables: Dict[str, Any] = field(default_factory=dict)
file_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"steps": [s.to_dict() for s in self.steps],
"variables": self.variables,
"file_path": self.file_path
}
class WorkflowManager:
"""
Manages workflow discovery, loading, and execution.
Workflows are defined in markdown files with YAML frontmatter
and step definitions.
Example workflow file (.praison/workflows/deploy.md):
```markdown
---
name: Deploy to Production
description: Deploy the application to production
variables:
environment: production
branch: main
---
## Step 1: Run Tests
Run all tests to ensure code quality.
```action
Run the test suite with pytest
```
## Step 2: Build
Build the application for production.
```action
Build the application with production settings
```
## Step 3: Deploy
Deploy to the production server.
```action
Deploy to {{environment}} from {{branch}}
```
```
Example usage:
```python
manager = WorkflowManager(workspace_path="/path/to/project")
# List available workflows
workflows = manager.list_workflows()
# Get a specific workflow
deploy = manager.get_workflow("deploy")
# Execute a workflow
results = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
variables={"branch": "release-1.0"}
)
```
"""
WORKFLOWS_DIR = ".praison/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
self,
workspace_path: Optional[str] = None,
verbose: int = 0
):
"""
Initialize WorkflowManager.
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
self._workflows: Dict[str, Workflow] = {}
self._loaded = False
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _ensure_loaded(self):
"""Lazy load workflows on first access."""
if not self._loaded:
self._discover_workflows()
self._loaded = True
def _parse_frontmatter(self, content: str) -> tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content."""
frontmatter = {}
body = content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
yaml_content = parts[1].strip()
body = parts[2].strip()
# Simple YAML parsing
for line in yaml_content.split("\n"):
line = line.strip()
if ":" in line and not line.startswith("#"):
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
# Handle basic types
if value.lower() in ("true", "false"):
value = value.lower() == "true"
elif value.isdigit():
value = int(value)
elif value.startswith('"') and value.endswith('"'):
value = value[1:-1]
frontmatter[key] = value
return frontmatter, body
def _parse_steps(self, body: str) -> List[WorkflowStep]:
"""Parse workflow steps from markdown body."""
steps = []
# Split by ## headers
step_pattern = re.compile(r'^##\s+(?:Step\s+\d+[:\s]*)?(.+)$', re.MULTILINE)
action_pattern = re.compile(r'```action\s*\n(.*?)\n```', re.DOTALL)
condition_pattern = re.compile(r'```condition\s*\n(.*?)\n```', re.DOTALL)
# Find all step headers
matches = list(step_pattern.finditer(body))
for i, match in enumerate(matches):
step_name = match.group(1).strip()
# Get content until next step or end
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(body)
step_content = body[start:end].strip()
# Extract action
action_match = action_pattern.search(step_content)
action = action_match.group(1).strip() if action_match else ""
# Extract condition
condition_match = condition_pattern.search(step_content)
condition = condition_match.group(1).strip() if condition_match else None
# Get description (text before action block)
description = step_content
if action_match:
description = step_content[:action_match.start()].strip()
# Clean up description (remove code blocks)
description = re.sub(r'```.*?```', '', description, flags=re.DOTALL).strip()
steps.append(WorkflowStep(
name=step_name,
description=description,
action=action,
condition=condition
))
return steps
def _load_workflow(self, file_path: Path) -> Optional[Workflow]:
"""Load a workflow from a file."""
try:
content = file_path.read_text(encoding="utf-8")
frontmatter, body = self._parse_frontmatter(content)
name = frontmatter.get("name", file_path.stem)
description = frontmatter.get("description", "")
variables = frontmatter.get("variables", {})
# Parse variables if it's a string (simple YAML)
if isinstance(variables, str):
variables = {}
steps = self._parse_steps(body)
workflow = Workflow(
name=name,
description=description,
steps=steps,
variables=variables,
file_path=str(file_path)
)
self._log(f"Loaded workflow '{name}' with {len(steps)} steps")
return workflow
except Exception as e:
self._log(f"Error loading workflow {file_path}: {e}", logging.WARNING)
return None
def _discover_workflows(self):
"""Discover all workflows in the workspace."""
self._workflows = {}
workflows_dir = self.workspace_path / self.WORKFLOWS_DIR.replace("/", os.sep)
if not workflows_dir.exists():
return
for ext in self.SUPPORTED_EXTENSIONS:
for file_path in workflows_dir.glob(f"*{ext}"):
workflow = self._load_workflow(file_path)
if workflow:
self._workflows[workflow.name.lower()] = workflow
self._log(f"Discovered {len(self._workflows)} workflows")
def reload(self):
"""Reload all workflows from disk."""
self._loaded = False
self._ensure_loaded()
def list_workflows(self) -> List[Workflow]:
"""List all available workflows."""
self._ensure_loaded()
return list(self._workflows.values())
def get_workflow(self, name: str) -> Optional[Workflow]:
"""Get a workflow by name."""
self._ensure_loaded()
return self._workflows.get(name.lower())
def _substitute_variables(
self,
text: str,
variables: Dict[str, Any]
) -> str:
"""Substitute {{variable}} placeholders in text."""
def replace(match):
var_name = match.group(1).strip()
return str(variables.get(var_name, match.group(0)))
return re.sub(r'\{\{(\w+)\}\}', replace, text)
def execute(
self,
workflow_name: str,
executor: Callable[[str], str],
variables: Optional[Dict[str, Any]] = None,
on_step: Optional[Callable[[WorkflowStep, int], None]] = None,
on_result: Optional[Callable[[WorkflowStep, str], None]] = None
) -> Dict[str, Any]:
"""
Execute a workflow.
Args:
workflow_name: Name of the workflow to execute
executor: Function to execute each step (e.g., agent.chat)
variables: Variables to substitute in steps
on_step: Callback before each step (step, index)
on_result: Callback after each step (step, result)
Returns:
Execution results with step outputs and status
"""
workflow = self.get_workflow(workflow_name)
if not workflow:
return {
"success": False,
"error": f"Workflow '{workflow_name}' not found",
"results": []
}
# Merge variables
all_variables = {**workflow.variables}
if variables:
all_variables.update(variables)
results = []
success = True
for i, step in enumerate(workflow.steps):
# Check condition
if step.condition:
condition = self._substitute_variables(step.condition, all_variables)
# Simple condition evaluation (could be enhanced)
if condition.lower() in ("false", "no", "skip", "0"):
results.append({
"step": step.name,
"status": "skipped",
"output": None
})
continue
# Callback before step
if on_step:
on_step(step, i)
# Substitute variables in action
action = self._substitute_variables(step.action, all_variables)
# Execute step
retries = 0
step_success = False
output = None
error = None
while retries <= step.max_retries and not step_success:
try:
output = executor(action)
step_success = True
except Exception as e:
error = str(e)
retries += 1
if retries <= step.max_retries:
self._log(f"Step '{step.name}' failed, retrying ({retries}/{step.max_retries})")
# Callback after step
if on_result and output:
on_result(step, output)
results.append({
"step": step.name,
"status": "success" if step_success else "failed",
"output": output,
"error": error
})
# Handle failure
if not step_success:
if step.on_error == "stop":
success = False
break
elif step.on_error == "continue":
continue
return {
"success": success,
"workflow": workflow.name,
"results": results
}
def create_workflow(
self,
name: str,
description: str = "",
steps: Optional[List[Dict[str, str]]] = None,
variables: Optional[Dict[str, Any]] = None
) -> Workflow:
"""
Create a new workflow file.
Args:
name: Workflow name
description: Workflow description
steps: List of step dicts with name, description, action
variables: Default variables
Returns:
Created Workflow object
"""
workflows_dir = self.workspace_path / self.WORKFLOWS_DIR.replace("/", os.sep)
workflows_dir.mkdir(parents=True, exist_ok=True)
file_path = workflows_dir / f"{name.lower().replace(' ', '_')}.md"
# Build content
lines = ["---"]
lines.append(f"name: {name}")
if description:
lines.append(f"description: {description}")
if variables:
lines.append("variables:")
for k, v in variables.items():
lines.append(f" {k}: {v}")
lines.append("---")
lines.append("")
# Add steps
workflow_steps = []
if steps:
for i, step in enumerate(steps, 1):
step_name = step.get("name", f"Step {i}")
step_desc = step.get("description", "")
step_action = step.get("action", "")
lines.append(f"## Step {i}: {step_name}")
if step_desc:
lines.append(step_desc)
lines.append("")
lines.append("```action")
lines.append(step_action)
lines.append("```")
lines.append("")
workflow_steps.append(WorkflowStep(
name=step_name,
description=step_desc,
action=step_action
))
# Write file
file_path.write_text("\n".join(lines), encoding="utf-8")
# Create and register workflow
workflow = Workflow(
name=name,
description=description,
steps=workflow_steps,
variables=variables or {},
file_path=str(file_path)
)
self._workflows[name.lower()] = workflow
self._log(f"Created workflow '{name}' at {file_path}")
return workflow
def get_stats(self) -> Dict[str, Any]:
"""Get workflow statistics."""
self._ensure_loaded()
total_steps = sum(len(w.steps) for w in self._workflows.values())
return {
"total_workflows": len(self._workflows),
"total_steps": total_steps,
"workflows": [w.name for w in self._workflows.values()]
}
def create_workflow_manager(
workspace_path: Optional[str] = None,
**kwargs
) -> WorkflowManager:
"""
Create a WorkflowManager instance.
Args:
workspace_path: Path to workspace
**kwargs: Additional configuration
Returns:
WorkflowManager instance
"""
return WorkflowManager(workspace_path=workspace_path, **kwargs)
@@ -0,0 +1,104 @@
"""
Planning Module for PraisonAI Agents.
Provides Planning Mode functionality similar to:
- Cursor Plan Mode
- Windsurf Planning Mode
- Claude Code Plan Mode
- Gemini CLI Plan Mode
- Codex CLI /plan command
Features:
- PlanningAgent for creating implementation plans
- Plan and PlanStep dataclasses for plan structure
- TodoList for tracking progress
- PlanStorage for persistence
- ApprovalCallback for plan approval flow
- Read-only mode for safe research
Usage:
from praisonaiagents import Agent, Task, PraisonAIAgents
agents = PraisonAIAgents(
agents=[agent1, agent2],
tasks=[task1, task2],
planning=True, # Enable planning mode
planning_llm="gpt-4o-mini" # Optional: custom LLM
)
result = agents.start()
"""
from .plan import Plan, PlanStep
from .todo import TodoList, TodoItem
from .storage import PlanStorage
from .planner import PlanningAgent
from .approval import ApprovalCallback
# Read-only tools that are allowed in plan mode
READ_ONLY_TOOLS = [
"read_file",
"list_directory",
"search_codebase",
"search_files",
"grep_search",
"find_files",
"web_search",
"get_file_content",
"list_files",
"read_document",
"search_web",
"fetch_url",
"get_context",
]
# Restricted tools that are blocked in plan mode
RESTRICTED_TOOLS = [
"write_file",
"create_file",
"delete_file",
"execute_command",
"run_command",
"shell_command",
"modify_file",
"edit_file",
"remove_file",
"move_file",
"copy_file",
"mkdir",
"rmdir",
"git_commit",
"git_push",
"npm_install",
"pip_install",
]
# Research tools that are safe for planning research
RESEARCH_TOOLS = [
"web_search",
"search_web",
"duckduckgo_search",
"tavily_search",
"brave_search",
"google_search",
"read_url",
"fetch_url",
"read_file",
"list_directory",
"search_codebase",
"grep_search",
"find_files",
]
__all__ = [
"Plan",
"PlanStep",
"TodoList",
"TodoItem",
"PlanStorage",
"PlanningAgent",
"ApprovalCallback",
"READ_ONLY_TOOLS",
"RESTRICTED_TOOLS",
"RESEARCH_TOOLS",
]
@@ -0,0 +1,274 @@
"""
ApprovalCallback for plan approval flow.
Provides approval functionality similar to:
- Cursor Plan Mode approval
- Claude Code plan approval
- Codex CLI /approvals
Features:
- Auto-approve option
- Custom approval functions
- Async support
- Plan modification during approval
"""
import asyncio
import inspect
import logging
from typing import Callable, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from .plan import Plan
logger = logging.getLogger(__name__)
class ApprovalCallback:
"""
Callback for plan approval flow.
Handles the approval process for plans before execution.
Can be configured for auto-approval or custom approval logic.
Attributes:
auto_approve: Whether to automatically approve all plans
approve_fn: Custom approval function
on_reject: Callback when plan is rejected
"""
def __init__(
self,
auto_approve: bool = False,
approve_fn: Optional[Callable[['Plan'], Union[bool, 'asyncio.Future']]] = None,
on_reject: Optional[Callable[['Plan'], None]] = None
):
"""
Initialize ApprovalCallback.
Args:
auto_approve: If True, automatically approve all plans
approve_fn: Custom function to determine approval
Should return True to approve, False to reject
Can be sync or async
on_reject: Optional callback when plan is rejected
"""
self.auto_approve = auto_approve
self.approve_fn = approve_fn
self.on_reject = on_reject
def __call__(self, plan: 'Plan') -> bool:
"""
Synchronously check if plan is approved.
Args:
plan: Plan to check for approval
Returns:
True if approved, False if rejected
"""
if self.auto_approve:
logger.debug(f"Auto-approving plan: {plan.name}")
plan.approve()
return True
if self.approve_fn is not None:
# Check if function is async
if inspect.iscoroutinefunction(self.approve_fn):
# Run async function synchronously
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(self.approve_fn(plan))
finally:
loop.close()
else:
result = self.approve_fn(plan)
if result:
plan.approve()
return True
else:
if self.on_reject:
self.on_reject(plan)
return False
# Default: require explicit approval (return False)
logger.debug(f"Plan requires explicit approval: {plan.name}")
return False
async def async_call(self, plan: 'Plan') -> bool:
"""
Asynchronously check if plan is approved.
Args:
plan: Plan to check for approval
Returns:
True if approved, False if rejected
"""
if self.auto_approve:
logger.debug(f"Auto-approving plan: {plan.name}")
plan.approve()
return True
if self.approve_fn is not None:
# Check if function is async
if inspect.iscoroutinefunction(self.approve_fn):
result = await self.approve_fn(plan)
else:
result = self.approve_fn(plan)
if result:
plan.approve()
return True
else:
if self.on_reject:
self.on_reject(plan)
return False
# Default: require explicit approval (return False)
logger.debug(f"Plan requires explicit approval: {plan.name}")
return False
@staticmethod
def console_approval(plan: 'Plan') -> bool:
"""
Interactive console approval.
Displays the plan and prompts for approval.
Args:
plan: Plan to approve
Returns:
True if user approves, False otherwise
"""
print("\n" + "=" * 60)
print("PLAN APPROVAL REQUIRED")
print("=" * 60)
print(plan.to_markdown())
print("=" * 60)
while True:
response = input("\nApprove this plan? [y/n/e(dit)]: ").strip().lower()
if response in ['y', 'yes']:
return True
elif response in ['n', 'no']:
return False
elif response in ['e', 'edit']:
print("\nPlan editing not implemented in console mode.")
print("Please modify the plan programmatically and re-submit.")
continue
else:
print("Please enter 'y' for yes, 'n' for no, or 'e' to edit.")
@staticmethod
def always_approve(plan: 'Plan') -> bool:
"""Always approve plans."""
return True
@staticmethod
def always_reject(plan: 'Plan') -> bool:
"""Always reject plans."""
return False
@staticmethod
def approve_if_small(plan: 'Plan', max_steps: int = 5) -> bool:
"""
Approve plans with few steps automatically.
Args:
plan: Plan to check
max_steps: Maximum number of steps to auto-approve
Returns:
True if plan has <= max_steps
"""
return len(plan.steps) <= max_steps
@staticmethod
def approve_if_no_dangerous_tools(plan: 'Plan') -> bool:
"""
Approve plans that don't use dangerous tools.
Args:
plan: Plan to check
Returns:
True if no dangerous tools are used
"""
from . import RESTRICTED_TOOLS
for step in plan.steps:
for tool in step.tools:
if tool in RESTRICTED_TOOLS:
logger.warning(f"Plan uses restricted tool: {tool}")
return False
return True
class InteractiveApproval:
"""
Interactive approval handler with rich UI support.
Provides a more sophisticated approval interface
with plan editing capabilities.
"""
def __init__(self, use_rich: bool = True):
"""
Initialize InteractiveApproval.
Args:
use_rich: Whether to use Rich library for display
"""
self.use_rich = use_rich
self._rich_available = False
if use_rich:
try:
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm
self._rich_available = True
self._console = Console()
except ImportError:
self._rich_available = False
def __call__(self, plan: 'Plan') -> bool:
"""
Display plan and get approval.
Args:
plan: Plan to approve
Returns:
True if approved
"""
if self._rich_available:
return self._rich_approval(plan)
else:
return ApprovalCallback.console_approval(plan)
def _rich_approval(self, plan: 'Plan') -> bool:
"""Rich library approval display."""
from rich.panel import Panel
from rich.prompt import Confirm
from rich.markdown import Markdown
# Display plan
md = Markdown(plan.to_markdown())
self._console.print(Panel(md, title="[bold blue]Plan Approval Required[/bold blue]"))
# Get approval
approved = Confirm.ask("Approve this plan?")
return approved
async def async_call(self, plan: 'Plan') -> bool:
"""Async version of approval."""
# For now, just call sync version
# Could be extended for async UI frameworks
return self(plan)
@@ -0,0 +1,364 @@
"""
Plan and PlanStep dataclasses for Planning Mode.
Provides structured representation of implementation plans
with support for:
- Step dependencies
- Status tracking
- Markdown serialization
- Progress calculation
"""
import uuid
import re
import yaml
import logging
from datetime import datetime
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any, Literal
logger = logging.getLogger(__name__)
@dataclass
class PlanStep:
"""
A single step in an implementation plan.
Attributes:
id: Unique identifier for the step
description: What this step does
agent: Name of the agent to execute this step
tools: List of tools needed for this step
dependencies: List of step IDs that must complete first
status: Current status (pending, in_progress, completed, skipped)
estimated_tokens: Estimated token usage for this step
"""
description: str
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
agent: Optional[str] = None
tools: List[str] = field(default_factory=list)
dependencies: List[str] = field(default_factory=list)
status: Literal["pending", "in_progress", "completed", "skipped"] = "pending"
estimated_tokens: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert PlanStep to dictionary."""
return {
"id": self.id,
"description": self.description,
"agent": self.agent,
"tools": self.tools,
"dependencies": self.dependencies,
"status": self.status,
"estimated_tokens": self.estimated_tokens
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PlanStep':
"""Create PlanStep from dictionary."""
return cls(
id=data.get("id", str(uuid.uuid4())[:8]),
description=data.get("description", ""),
agent=data.get("agent"),
tools=data.get("tools", []),
dependencies=data.get("dependencies", []),
status=data.get("status", "pending"),
estimated_tokens=data.get("estimated_tokens", 0)
)
def mark_complete(self) -> None:
"""Mark this step as completed."""
self.status = "completed"
def mark_in_progress(self) -> None:
"""Mark this step as in progress."""
self.status = "in_progress"
def mark_skipped(self) -> None:
"""Mark this step as skipped."""
self.status = "skipped"
def is_ready(self, completed_steps: List[str]) -> bool:
"""
Check if this step is ready to execute.
Args:
completed_steps: List of completed step IDs
Returns:
True if all dependencies are satisfied
"""
if self.status != "pending":
return False
return all(dep in completed_steps for dep in self.dependencies)
@dataclass
class Plan:
"""
An implementation plan with multiple steps.
Attributes:
id: Unique identifier for the plan
name: Human-readable name
description: Detailed description of what the plan accomplishes
steps: List of PlanStep objects
status: Current status (draft, approved, executing, completed)
created_at: When the plan was created
approved_at: When the plan was approved (if applicable)
file_path: Path where the plan is stored (if saved)
"""
name: str
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
description: str = ""
steps: List[PlanStep] = field(default_factory=list)
status: Literal["draft", "approved", "executing", "completed", "cancelled"] = "draft"
created_at: datetime = field(default_factory=datetime.now)
approved_at: Optional[datetime] = None
file_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert Plan to dictionary."""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"steps": [step.to_dict() for step in self.steps],
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
"file_path": self.file_path
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Plan':
"""Create Plan from dictionary."""
steps = [
PlanStep.from_dict(s) if isinstance(s, dict) else s
for s in data.get("steps", [])
]
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at)
elif created_at is None:
created_at = datetime.now()
approved_at = data.get("approved_at")
if isinstance(approved_at, str):
approved_at = datetime.fromisoformat(approved_at)
return cls(
id=data.get("id", str(uuid.uuid4())[:8]),
name=data.get("name", "Untitled Plan"),
description=data.get("description", ""),
steps=steps,
status=data.get("status", "draft"),
created_at=created_at,
approved_at=approved_at,
file_path=data.get("file_path")
)
def to_markdown(self) -> str:
"""
Convert Plan to markdown format.
Returns:
Markdown string representation of the plan
"""
# YAML frontmatter
frontmatter = {
"id": self.id,
"name": self.name,
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"approved_at": self.approved_at.isoformat() if self.approved_at else None
}
lines = [
"---",
yaml.dump(frontmatter, default_flow_style=False).strip(),
"---",
"",
f"# {self.name}",
"",
]
if self.description:
lines.extend([self.description, ""])
lines.extend(["## Steps", ""])
status_icons = {
"pending": "⬜",
"in_progress": "🔄",
"completed": "✅",
"skipped": "⏭️"
}
for i, step in enumerate(self.steps, 1):
icon = status_icons.get(step.status, "⬜")
lines.append(f"{i}. {icon} {step.description}")
if step.agent:
lines.append(f" - Agent: {step.agent}")
if step.tools:
lines.append(f" - Tools: {', '.join(step.tools)}")
if step.dependencies:
lines.append(f" - Depends on: {', '.join(step.dependencies)}")
lines.append("")
return "\n".join(lines)
@classmethod
def from_markdown(cls, markdown: str) -> 'Plan':
"""
Create Plan from markdown format.
Args:
markdown: Markdown string with YAML frontmatter
Returns:
Plan instance
"""
# Extract YAML frontmatter
frontmatter_match = re.match(r'^---\n(.*?)\n---', markdown, re.DOTALL)
data = {}
if frontmatter_match:
try:
data = yaml.safe_load(frontmatter_match.group(1))
except yaml.YAMLError:
logger.warning("Failed to parse YAML frontmatter")
# Extract steps from markdown
steps = []
# Pattern to capture status icon and description
step_pattern = r'^\d+\.\s*([⬜🔄✅⏭️]?)\s*(.+?)$'
# Map icons to status
icon_to_status = {
"⬜": "pending",
"🔄": "in_progress",
"✅": "completed",
"⏭️": "skipped",
"": "pending"
}
for match in re.finditer(step_pattern, markdown, re.MULTILINE):
icon = match.group(1).strip()
description = match.group(2).strip()
status = icon_to_status.get(icon, "pending")
steps.append(PlanStep(description=description, status=status))
return cls(
id=data.get("id", str(uuid.uuid4())[:8]),
name=data.get("name", "Untitled Plan"),
description=data.get("description", ""),
steps=steps,
status=data.get("status", "draft")
)
def add_step(self, step: PlanStep) -> None:
"""Add a step to the plan."""
self.steps.append(step)
def remove_step(self, step_id: str) -> bool:
"""
Remove a step from the plan.
Args:
step_id: ID of the step to remove
Returns:
True if step was removed, False if not found
"""
for i, step in enumerate(self.steps):
if step.id == step_id:
self.steps.pop(i)
return True
return False
def get_step(self, step_id: str) -> Optional[PlanStep]:
"""
Get a step by ID.
Args:
step_id: ID of the step to find
Returns:
PlanStep if found, None otherwise
"""
for step in self.steps:
if step.id == step_id:
return step
return None
def approve(self) -> None:
"""Approve the plan for execution."""
self.status = "approved"
self.approved_at = datetime.now()
def start_execution(self) -> None:
"""Mark the plan as executing."""
self.status = "executing"
def complete(self) -> None:
"""Mark the plan as completed."""
self.status = "completed"
def cancel(self) -> None:
"""Cancel the plan."""
self.status = "cancelled"
@property
def progress(self) -> float:
"""
Calculate plan progress.
Returns:
Float between 0.0 and 1.0 representing completion percentage
"""
if not self.steps:
return 1.0
completed = sum(1 for s in self.steps if s.status == "completed")
return completed / len(self.steps)
@property
def is_complete(self) -> bool:
"""Check if all steps are completed."""
return all(s.status == "completed" for s in self.steps)
@property
def completed_step_ids(self) -> List[str]:
"""Get list of completed step IDs."""
return [s.id for s in self.steps if s.status == "completed"]
def get_next_steps(self) -> List[PlanStep]:
"""
Get steps that are ready to execute.
Returns:
List of steps with satisfied dependencies
"""
completed = self.completed_step_ids
return [s for s in self.steps if s.is_ready(completed)]
def update_step_status(self, step_id: str, status: str) -> bool:
"""
Update the status of a step.
Args:
step_id: ID of the step to update
status: New status
Returns:
True if step was updated, False if not found
"""
step = self.get_step(step_id)
if step:
step.status = status
return True
return False
@@ -0,0 +1,525 @@
"""
PlanningAgent for creating implementation plans.
Provides planning functionality similar to:
- CrewAI AgentPlanner
- Cursor Plan Mode
- Claude Code Plan Mode
Features:
- Analyze requests and create detailed plans
- Read-only mode for safe research
- Plan refinement based on feedback
- Context analysis
"""
import json
import logging
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from .plan import Plan, PlanStep
# Define READ_ONLY_TOOLS locally to avoid circular import
READ_ONLY_TOOLS = [
"read_file",
"list_directory",
"search_codebase",
"search_files",
"grep_search",
"find_files",
"web_search",
"get_file_content",
"list_files",
"read_document",
"search_web",
"fetch_url",
"get_context",
]
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..task.task import Task
logger = logging.getLogger(__name__)
class PlanningAgent:
"""
Agent specialized in creating implementation plans.
The PlanningAgent analyzes requests, researches the codebase,
and creates detailed step-by-step plans before execution.
Attributes:
llm_model: LLM model to use for planning
read_only: Whether to restrict to read-only tools
allowed_tools: List of tools available in planning mode
verbose: Verbosity level
"""
PLANNING_PROMPT = '''You are an expert planning specialist. Your task is to analyze the request and create a detailed, actionable implementation plan.
## Request
{request}
## Available Agents
{agents_info}
## Available Tasks (if any)
{tasks_info}
## Context
{context}
## Instructions
1. Analyze the request thoroughly
2. Break it down into clear, actionable steps
3. Assign appropriate agents to each step
4. Identify dependencies between steps
5. Consider potential risks and edge cases
## Output Format
Respond with a JSON object in this exact format:
{{
"name": "Brief plan name",
"description": "Detailed description of what this plan accomplishes",
"steps": [
{{
"description": "Clear description of what this step does",
"agent": "Name of the agent to execute this step (or null)",
"tools": ["list", "of", "tools", "needed"],
"dependencies": ["list of step IDs this depends on"]
}}
]
}}
Important:
- Each step should be atomic and clearly defined
- Dependencies should reference step indices (e.g., "step_0", "step_1")
- Be specific about what each step accomplishes
- Consider the order of operations carefully'''
REFINEMENT_PROMPT = '''You are refining an existing implementation plan based on feedback.
## Original Plan
{original_plan}
## Feedback
{feedback}
## Instructions
1. Review the original plan
2. Apply the feedback to improve the plan
3. Maintain the same JSON format
4. Keep steps that are still valid
5. Add, modify, or remove steps as needed
## Output Format
Respond with a JSON object in the same format as the original plan:
{{
"name": "Updated plan name",
"description": "Updated description",
"steps": [...]
}}'''
ANALYSIS_PROMPT = '''Analyze the following context and provide insights for planning:
## Context
{context}
## Instructions
1. Identify the key components and their relationships
2. Note any patterns or conventions
3. Highlight potential challenges
4. Suggest best practices to follow
Provide a concise analysis that will help in creating an implementation plan.'''
def __init__(
self,
llm: str = "gpt-4o-mini",
read_only: bool = True,
verbose: int = 0,
tools: Optional[List] = None,
reasoning: bool = False,
max_reasoning_steps: int = 5
):
"""
Initialize PlanningAgent.
Args:
llm: LLM model to use for planning
read_only: Whether to restrict to read-only tools
verbose: Verbosity level
tools: Optional list of tools for research during planning
reasoning: Whether to enable reasoning mode with chain-of-thought
max_reasoning_steps: Maximum reasoning iterations (default 5)
"""
self.llm_model = llm
self.read_only = read_only
self.verbose = verbose
self.tools = tools or []
self.reasoning = reasoning
self.max_reasoning_steps = max_reasoning_steps
self.allowed_tools = READ_ONLY_TOOLS.copy() if read_only else []
self._llm = None
self._agent = None
def _get_llm(self):
"""Lazy load LLM instance."""
if self._llm is None:
from ..llm import LLM
self._llm = LLM(model=self.llm_model)
return self._llm
def _get_agent(self):
"""
Lazy load Agent instance for tool-enabled planning.
Returns:
Agent instance if tools are provided, None otherwise
"""
if not self.tools:
return None
if self._agent is None:
from ..agent.agent import Agent
self._agent = Agent(
name="Planning Research Agent",
role="Research and Planning Specialist",
goal="Research information and create detailed implementation plans",
llm=self.llm_model,
tools=self.tools,
verbose=self.verbose > 0
)
return self._agent
def _format_agents_info(self, agents: List['Agent']) -> str:
"""
Format agent information for the prompt.
Args:
agents: List of Agent instances
Returns:
Formatted string describing agents
"""
if not agents:
return "No agents specified"
lines = []
for agent in agents:
name = getattr(agent, 'name', 'Unnamed')
role = getattr(agent, 'role', 'General')
goal = getattr(agent, 'goal', '')
line = f"- **{name}** ({role})"
if goal:
line += f": {goal}"
lines.append(line)
return "\n".join(lines)
def _format_tasks_info(self, tasks: Optional[List['Task']]) -> str:
"""
Format task information for the prompt.
Args:
tasks: List of Task instances
Returns:
Formatted string describing tasks
"""
if not tasks:
return "No predefined tasks"
lines = []
for task in tasks:
name = getattr(task, 'name', None) or getattr(task, 'description', 'Unnamed')[:50]
desc = getattr(task, 'description', '')[:100]
lines.append(f"- {name}: {desc}")
return "\n".join(lines)
def _parse_plan_response(self, response: str) -> Plan:
"""
Parse LLM response into a Plan object.
Args:
response: JSON string from LLM
Returns:
Plan instance
"""
try:
# Try to extract JSON from response
# Handle cases where LLM includes extra text
json_start = response.find('{')
json_end = response.rfind('}') + 1
if json_start >= 0 and json_end > json_start:
json_str = response[json_start:json_end]
data = json.loads(json_str)
else:
raise ValueError("No JSON found in response")
# Create steps
steps = []
for i, step_data in enumerate(data.get("steps", [])):
step = PlanStep(
id=f"step_{i}",
description=step_data.get("description", ""),
agent=step_data.get("agent"),
tools=step_data.get("tools", []),
dependencies=step_data.get("dependencies", [])
)
steps.append(step)
return Plan(
name=data.get("name", "Implementation Plan"),
description=data.get("description", ""),
steps=steps
)
except (json.JSONDecodeError, ValueError) as e:
logger.warning(f"Failed to parse plan response: {e}")
# Return a simple plan with the response as a single step
return Plan(
name="Implementation Plan",
description="Auto-generated plan",
steps=[PlanStep(description=response[:500])]
)
async def create_plan(
self,
request: str,
agents: List['Agent'],
tasks: Optional[List['Task']] = None,
context: Optional[str] = None
) -> Plan:
"""
Create an implementation plan for the given request.
Args:
request: The user's request or goal
agents: List of available agents
tasks: Optional list of predefined tasks
context: Optional additional context
Returns:
Plan instance with steps
"""
llm = self._get_llm()
# Format the prompt
prompt = self.PLANNING_PROMPT.format(
request=request,
agents_info=self._format_agents_info(agents),
tasks_info=self._format_tasks_info(tasks),
context=context or "No additional context provided"
)
if self.verbose >= 2:
logger.info(f"Creating plan for: {request[:100]}...")
# Get LLM response
response = await llm.get_response_async(prompt)
# Parse response into Plan
plan = self._parse_plan_response(response)
if self.verbose >= 1:
logger.info(f"Created plan '{plan.name}' with {len(plan.steps)} steps")
return plan
def create_plan_sync(
self,
request: str,
agents: List['Agent'],
tasks: Optional[List['Task']] = None,
context: Optional[str] = None
) -> Plan:
"""
Synchronous version of create_plan.
Args:
request: The user's request or goal
agents: List of available agents
tasks: Optional list of predefined tasks
context: Optional additional context
Returns:
Plan instance with steps
"""
# Format the prompt
prompt = self.PLANNING_PROMPT.format(
request=request,
agents_info=self._format_agents_info(agents),
tasks_info=self._format_tasks_info(tasks),
context=context or "No additional context provided"
)
if self.verbose >= 2:
logger.info(f"Creating plan for: {request[:100]}...")
# Use Agent with tools if available, otherwise use bare LLM
agent = self._get_agent()
if agent is not None:
# Use Agent.chat() for tool-enabled planning
response = agent.chat(prompt)
else:
# Use bare LLM for simple planning
llm = self._get_llm()
response = llm.get_response(prompt)
# Parse response into Plan
plan = self._parse_plan_response(response)
if self.verbose >= 1:
logger.info(f"Created plan '{plan.name}' with {len(plan.steps)} steps")
return plan
async def refine_plan(
self,
plan: Plan,
feedback: str
) -> Plan:
"""
Refine an existing plan based on feedback.
Args:
plan: Original plan to refine
feedback: User feedback for refinement
Returns:
Refined Plan instance
"""
llm = self._get_llm()
# Format the prompt
prompt = self.REFINEMENT_PROMPT.format(
original_plan=json.dumps(plan.to_dict(), indent=2),
feedback=feedback
)
if self.verbose >= 2:
logger.info(f"Refining plan based on feedback: {feedback[:100]}...")
# Get LLM response
response = await llm.get_response_async(prompt)
# Parse response into Plan
refined = self._parse_plan_response(response)
# Preserve original ID
refined.id = plan.id
if self.verbose >= 1:
logger.info(f"Refined plan '{refined.name}' with {len(refined.steps)} steps")
return refined
def refine_plan_sync(
self,
plan: Plan,
feedback: str
) -> Plan:
"""
Synchronous version of refine_plan.
Args:
plan: Original plan to refine
feedback: User feedback for refinement
Returns:
Refined Plan instance
"""
llm = self._get_llm()
# Format the prompt
prompt = self.REFINEMENT_PROMPT.format(
original_plan=json.dumps(plan.to_dict(), indent=2),
feedback=feedback
)
if self.verbose >= 2:
logger.info(f"Refining plan based on feedback: {feedback[:100]}...")
# Get LLM response
response = llm.get_response(prompt)
# Parse response into Plan
refined = self._parse_plan_response(response)
# Preserve original ID
refined.id = plan.id
if self.verbose >= 1:
logger.info(f"Refined plan '{refined.name}' with {len(refined.steps)} steps")
return refined
async def analyze_context(
self,
context: str
) -> str:
"""
Analyze context and provide insights for planning.
Args:
context: Context to analyze (e.g., codebase summary)
Returns:
Analysis string
"""
llm = self._get_llm()
prompt = self.ANALYSIS_PROMPT.format(context=context)
if self.verbose >= 2:
logger.info("Analyzing context...")
response = await llm.get_response_async(prompt)
return response
def analyze_context_sync(
self,
context: str
) -> str:
"""
Synchronous version of analyze_context.
Args:
context: Context to analyze
Returns:
Analysis string
"""
llm = self._get_llm()
prompt = self.ANALYSIS_PROMPT.format(context=context)
if self.verbose >= 2:
logger.info("Analyzing context...")
response = llm.get_response(prompt)
return response
def is_tool_allowed(self, tool_name: str) -> bool:
"""
Check if a tool is allowed in planning mode.
Args:
tool_name: Name of the tool to check
Returns:
True if tool is allowed
"""
if not self.read_only:
return True
return tool_name in self.allowed_tools
@@ -0,0 +1,325 @@
"""
PlanStorage for persisting plans and todo lists.
Provides storage functionality similar to:
- Cursor .cursor/plans/
- Windsurf ~/.codeium/windsurf/brain/
- Codex ~/.codex/sessions/
Features:
- Save/load plans as markdown files
- Save/load todo lists as JSON
- Auto-discovery of existing plans
- Plan versioning
"""
import os
import json
import logging
from pathlib import Path
from datetime import datetime
from typing import List, Optional, Dict, Any
from .plan import Plan
from .todo import TodoList
logger = logging.getLogger(__name__)
class PlanStorage:
"""
Storage manager for plans and todo lists.
Attributes:
base_path: Base directory for storage
plans_dir: Directory for plan files
todos_dir: Directory for todo files
"""
DEFAULT_BASE_PATH = ".praison"
def __init__(self, base_path: Optional[str] = None):
"""
Initialize PlanStorage.
Args:
base_path: Base directory for storage. Defaults to .praison/
"""
self.base_path = base_path or self.DEFAULT_BASE_PATH
self.plans_dir = os.path.join(self.base_path, "plans")
self.todos_dir = os.path.join(self.base_path, "todos")
# Create directories if they don't exist
self._ensure_directories()
def _ensure_directories(self) -> None:
"""Create storage directories if they don't exist."""
os.makedirs(self.plans_dir, exist_ok=True)
os.makedirs(self.todos_dir, exist_ok=True)
def _get_plan_filename(self, plan: Plan) -> str:
"""
Generate filename for a plan.
Args:
plan: Plan to generate filename for
Returns:
Filename string
"""
date_str = plan.created_at.strftime("%Y-%m-%d") if plan.created_at else datetime.now().strftime("%Y-%m-%d")
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in plan.name[:30])
return f"plan_{date_str}_{plan.id}_{safe_name}.md"
def _get_plan_path(self, plan_id: str) -> Optional[str]:
"""
Find the path for a plan by ID.
Args:
plan_id: ID of the plan to find
Returns:
Path to the plan file, or None if not found
"""
for filename in os.listdir(self.plans_dir):
if plan_id in filename and filename.endswith(".md"):
return os.path.join(self.plans_dir, filename)
return None
def save_plan(self, plan: Plan, filename: Optional[str] = None) -> str:
"""
Save a plan to storage.
Args:
plan: Plan to save
filename: Optional custom filename
Returns:
Path to the saved file
"""
if filename is None:
filename = self._get_plan_filename(plan)
path = os.path.join(self.plans_dir, filename)
# Convert plan to markdown
markdown = plan.to_markdown()
with open(path, "w", encoding="utf-8") as f:
f.write(markdown)
plan.file_path = path
logger.debug(f"Saved plan to {path}")
return path
def load_plan(self, plan_id: str) -> Optional[Plan]:
"""
Load a plan from storage.
Args:
plan_id: ID of the plan to load
Returns:
Plan instance, or None if not found
"""
path = self._get_plan_path(plan_id)
if path is None or not os.path.exists(path):
logger.debug(f"Plan {plan_id} not found")
return None
with open(path, "r", encoding="utf-8") as f:
markdown = f.read()
plan = Plan.from_markdown(markdown)
plan.file_path = path
logger.debug(f"Loaded plan from {path}")
return plan
def load_plan_from_file(self, path: str) -> Optional[Plan]:
"""
Load a plan from a specific file path.
Args:
path: Path to the plan file
Returns:
Plan instance, or None if file doesn't exist
"""
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
markdown = f.read()
plan = Plan.from_markdown(markdown)
plan.file_path = path
return plan
def list_plans(self) -> List[Dict[str, Any]]:
"""
List all saved plans.
Returns:
List of plan metadata dictionaries
"""
plans = []
for filename in os.listdir(self.plans_dir):
if filename.endswith(".md"):
path = os.path.join(self.plans_dir, filename)
# Try to load and get metadata
try:
plan = self.load_plan_from_file(path)
if plan:
plans.append({
"id": plan.id,
"name": plan.name,
"status": plan.status,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"file_path": path
})
except Exception as e:
logger.warning(f"Failed to load plan from {path}: {e}")
# Sort by creation date (newest first)
plans.sort(key=lambda p: p.get("created_at", ""), reverse=True)
return plans
def delete_plan(self, plan_id: str) -> bool:
"""
Delete a plan from storage.
Args:
plan_id: ID of the plan to delete
Returns:
True if deleted, False if not found
"""
path = self._get_plan_path(plan_id)
if path and os.path.exists(path):
os.remove(path)
logger.debug(f"Deleted plan {plan_id}")
return True
return False
def save_todo(self, todo: TodoList, name: str = "current") -> str:
"""
Save a todo list to storage.
Args:
todo: TodoList to save
name: Name for the todo file (without extension)
Returns:
Path to the saved file
"""
filename = f"{name}.json"
path = os.path.join(self.todos_dir, filename)
with open(path, "w", encoding="utf-8") as f:
f.write(todo.to_json())
logger.debug(f"Saved todo list to {path}")
return path
def load_todo(self, name: str = "current") -> Optional[TodoList]:
"""
Load a todo list from storage.
Args:
name: Name of the todo file (without extension)
Returns:
TodoList instance, or None if not found
"""
filename = f"{name}.json"
path = os.path.join(self.todos_dir, filename)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
json_str = f.read()
logger.debug(f"Loaded todo list from {path}")
return TodoList.from_json(json_str)
def list_todos(self) -> List[str]:
"""
List all saved todo lists.
Returns:
List of todo list names
"""
todos = []
for filename in os.listdir(self.todos_dir):
if filename.endswith(".json"):
name = filename[:-5] # Remove .json extension
todos.append(name)
return todos
def delete_todo(self, name: str) -> bool:
"""
Delete a todo list from storage.
Args:
name: Name of the todo file (without extension)
Returns:
True if deleted, False if not found
"""
filename = f"{name}.json"
path = os.path.join(self.todos_dir, filename)
if os.path.exists(path):
os.remove(path)
logger.debug(f"Deleted todo list {name}")
return True
return False
def get_latest_plan(self) -> Optional[Plan]:
"""
Get the most recently created plan.
Returns:
Most recent Plan, or None if no plans exist
"""
plans = self.list_plans()
if not plans:
return None
return self.load_plan(plans[0]["id"])
def cleanup_old_plans(self, keep_count: int = 10) -> int:
"""
Remove old plans, keeping only the most recent ones.
Args:
keep_count: Number of plans to keep
Returns:
Number of plans deleted
"""
plans = self.list_plans()
if len(plans) <= keep_count:
return 0
deleted = 0
for plan in plans[keep_count:]:
if self.delete_plan(plan["id"]):
deleted += 1
return deleted
@@ -0,0 +1,415 @@
"""
TodoList and TodoItem classes for Planning Mode.
Provides task tracking functionality similar to:
- Cursor Agent To-Dos
- Windsurf Todo Lists
- Claude Code TodoRead/TodoWrite
Features:
- Create todo lists from plans
- Track item completion
- Dependencies between items
- Markdown and JSON serialization
"""
import uuid
import json
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any, Literal, Union, TYPE_CHECKING
if TYPE_CHECKING:
from .plan import Plan
logger = logging.getLogger(__name__)
@dataclass
class TodoItem:
"""
A single todo item.
Attributes:
id: Unique identifier
description: What needs to be done
status: Current status (pending, in_progress, completed)
dependencies: List of item IDs that must complete first
agent: Name of the agent responsible
priority: Priority level (low, medium, high)
notes: Additional notes
"""
description: str
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
status: Literal["pending", "in_progress", "completed"] = "pending"
dependencies: List[str] = field(default_factory=list)
agent: Optional[str] = None
priority: Literal["low", "medium", "high"] = "medium"
notes: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Convert TodoItem to dictionary."""
return {
"id": self.id,
"description": self.description,
"status": self.status,
"dependencies": self.dependencies,
"agent": self.agent,
"priority": self.priority,
"notes": self.notes
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TodoItem':
"""Create TodoItem from dictionary."""
return cls(
id=data.get("id", str(uuid.uuid4())[:8]),
description=data.get("description", ""),
status=data.get("status", "pending"),
dependencies=data.get("dependencies", []),
agent=data.get("agent"),
priority=data.get("priority", "medium"),
notes=data.get("notes", "")
)
def complete(self) -> None:
"""Mark this item as completed."""
self.status = "completed"
def start(self) -> None:
"""Mark this item as in progress."""
self.status = "in_progress"
def reset(self) -> None:
"""Reset this item to pending."""
self.status = "pending"
def is_ready(self, completed_ids: List[str]) -> bool:
"""
Check if this item is ready to start.
Args:
completed_ids: List of completed item IDs
Returns:
True if all dependencies are satisfied
"""
if self.status != "pending":
return False
return all(dep in completed_ids for dep in self.dependencies)
@dataclass
class TodoList:
"""
A list of todo items with tracking capabilities.
Attributes:
items: List of TodoItem objects
auto_update: Whether to auto-update from plan changes
name: Optional name for the todo list
"""
items: List[TodoItem] = field(default_factory=list)
auto_update: bool = True
name: str = "Todo List"
def add(self, item: Union[TodoItem, str]) -> TodoItem:
"""
Add an item to the list.
Args:
item: TodoItem or description string
Returns:
The added TodoItem
"""
if isinstance(item, str):
item = TodoItem(description=item)
self.items.append(item)
return item
def remove(self, item_id: str) -> bool:
"""
Remove an item from the list.
Args:
item_id: ID of the item to remove
Returns:
True if item was removed, False if not found
"""
for i, item in enumerate(self.items):
if item.id == item_id:
self.items.pop(i)
return True
return False
def get(self, item_id: str) -> Optional[TodoItem]:
"""
Get an item by ID.
Args:
item_id: ID of the item to find
Returns:
TodoItem if found, None otherwise
"""
for item in self.items:
if item.id == item_id:
return item
return None
def complete(self, item_id: str) -> bool:
"""
Mark an item as completed.
Args:
item_id: ID of the item to complete
Returns:
True if item was completed, False if not found
"""
item = self.get(item_id)
if item:
item.complete()
return True
return False
def start(self, item_id: str) -> bool:
"""
Mark an item as in progress.
Args:
item_id: ID of the item to start
Returns:
True if item was started, False if not found
"""
item = self.get(item_id)
if item:
item.start()
return True
return False
@property
def pending(self) -> List[TodoItem]:
"""Get all pending items."""
return [item for item in self.items if item.status == "pending"]
@property
def in_progress(self) -> List[TodoItem]:
"""Get all in-progress items."""
return [item for item in self.items if item.status == "in_progress"]
@property
def completed(self) -> List[TodoItem]:
"""Get all completed items."""
return [item for item in self.items if item.status == "completed"]
@property
def progress(self) -> float:
"""
Calculate completion progress.
Returns:
Float between 0.0 and 1.0
"""
if not self.items:
return 1.0
return len(self.completed) / len(self.items)
@property
def is_complete(self) -> bool:
"""Check if all items are completed."""
return all(item.status == "completed" for item in self.items)
@property
def completed_ids(self) -> List[str]:
"""Get list of completed item IDs."""
return [item.id for item in self.completed]
def get_ready_items(self) -> List[TodoItem]:
"""
Get items that are ready to start.
Returns:
List of items with satisfied dependencies
"""
completed = self.completed_ids
return [item for item in self.items if item.is_ready(completed)]
def to_markdown(self) -> str:
"""
Convert TodoList to markdown format.
Returns:
Markdown string with checkbox format
"""
lines = [f"# {self.name}", ""]
for item in self.items:
checkbox = "[x]" if item.status == "completed" else "[ ]"
lines.append(f"- {checkbox} {item.description}")
if item.agent:
lines.append(f" - Agent: {item.agent}")
if item.dependencies:
lines.append(f" - Depends on: {', '.join(item.dependencies)}")
if item.notes:
lines.append(f" - Notes: {item.notes}")
return "\n".join(lines)
@classmethod
def from_markdown(cls, markdown: str) -> 'TodoList':
"""
Create TodoList from markdown format.
Args:
markdown: Markdown string with checkbox format
Returns:
TodoList instance
"""
import re
items = []
# Match checkbox items: - [ ] or - [x]
pattern = r'^-\s*\[([ x])\]\s*(.+?)$'
for match in re.finditer(pattern, markdown, re.MULTILINE):
checked = match.group(1) == 'x'
description = match.group(2).strip()
item = TodoItem(
description=description,
status="completed" if checked else "pending"
)
items.append(item)
return cls(items=items)
def to_json(self) -> str:
"""
Serialize TodoList to JSON.
Returns:
JSON string
"""
data = {
"name": self.name,
"auto_update": self.auto_update,
"items": [item.to_dict() for item in self.items]
}
return json.dumps(data, indent=2)
@classmethod
def from_json(cls, json_str: str) -> 'TodoList':
"""
Create TodoList from JSON.
Args:
json_str: JSON string
Returns:
TodoList instance
"""
data = json.loads(json_str)
items = [TodoItem.from_dict(item) for item in data.get("items", [])]
return cls(
items=items,
auto_update=data.get("auto_update", True),
name=data.get("name", "Todo List")
)
def to_dict(self) -> Dict[str, Any]:
"""Convert TodoList to dictionary."""
return {
"name": self.name,
"auto_update": self.auto_update,
"items": [item.to_dict() for item in self.items]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TodoList':
"""Create TodoList from dictionary."""
items = [TodoItem.from_dict(item) for item in data.get("items", [])]
return cls(
items=items,
auto_update=data.get("auto_update", True),
name=data.get("name", "Todo List")
)
@classmethod
def from_plan(cls, plan: 'Plan') -> 'TodoList':
"""
Create TodoList from a Plan.
Args:
plan: Plan instance
Returns:
TodoList with items matching plan steps
"""
items = []
for step in plan.steps:
item = TodoItem(
id=step.id,
description=step.description,
status=step.status if step.status in ["pending", "in_progress", "completed"] else "pending",
dependencies=step.dependencies,
agent=step.agent
)
items.append(item)
return cls(
items=items,
name=f"Todo: {plan.name}"
)
def sync_with_plan(self, plan: 'Plan') -> None:
"""
Synchronize TodoList with a Plan.
Updates item statuses to match plan step statuses.
Args:
plan: Plan to sync with
"""
step_statuses = {step.id: step.status for step in plan.steps}
for item in self.items:
if item.id in step_statuses:
status = step_statuses[item.id]
if status in ["pending", "in_progress", "completed"]:
item.status = status
def update_from_plan(self, plan: 'Plan') -> None:
"""
Update TodoList to match Plan structure.
Adds new items for new steps, removes items for removed steps.
Args:
plan: Plan to update from
"""
# Get current step IDs
step_ids = {step.id for step in plan.steps}
item_ids = {item.id for item in self.items}
# Remove items for deleted steps
self.items = [item for item in self.items if item.id in step_ids]
# Add items for new steps
for step in plan.steps:
if step.id not in item_ids:
self.add(TodoItem(
id=step.id,
description=step.description,
dependencies=step.dependencies,
agent=step.agent
))
# Sync statuses
self.sync_with_plan(plan)
@@ -173,6 +173,51 @@ TOOL_MAPPINGS = {
'cot_save': ('.train.data.generatecot', 'GenerateCOT'), # Save single QA to file
'cot_upload_to_huggingface': ('.train.data.generatecot', 'GenerateCOT'), # Upload dataset to HuggingFace
'cot_tools': ('.train.data.generatecot', 'GenerateCOT'), # Full toolkit access
# Claude Memory Tool (Anthropic Beta)
'ClaudeMemoryTool': ('.claude_memory_tool', 'ClaudeMemoryTool'),
# Tavily Tools (requires TAVILY_API_KEY)
'tavily': ('.tavily_tools', None),
'tavily_search': ('.tavily_tools', None),
'tavily_extract': ('.tavily_tools', None),
'tavily_crawl': ('.tavily_tools', None),
'tavily_map': ('.tavily_tools', None),
'tavily_search_async': ('.tavily_tools', None),
'tavily_extract_async': ('.tavily_tools', None),
'TavilyTools': ('.tavily_tools', 'TavilyTools'),
'tavily_tools': ('.tavily_tools', None),
# You.com Tools (requires YDC_API_KEY)
'ydc': ('.youdotcom_tools', None),
'ydc_search': ('.youdotcom_tools', None),
'ydc_contents': ('.youdotcom_tools', None),
'ydc_news': ('.youdotcom_tools', None),
'ydc_images': ('.youdotcom_tools', None),
'YouTools': ('.youdotcom_tools', 'YouTools'),
'youdotcom_tools': ('.youdotcom_tools', None),
# Exa Tools (requires EXA_API_KEY)
'exa': ('.exa_tools', None),
'exa_search': ('.exa_tools', None),
'exa_search_contents': ('.exa_tools', None),
'exa_find_similar': ('.exa_tools', None),
'exa_answer': ('.exa_tools', None),
'exa_search_async': ('.exa_tools', None),
'exa_search_contents_async': ('.exa_tools', None),
'exa_answer_async': ('.exa_tools', None),
'ExaTools': ('.exa_tools', 'ExaTools'),
'exa_tools': ('.exa_tools', None),
# Crawl4AI Tools (async web crawling)
'crawl4ai': ('.crawl4ai_tools', None),
'crawl4ai_many': ('.crawl4ai_tools', None),
'crawl4ai_extract': ('.crawl4ai_tools', None),
'crawl4ai_llm_extract': ('.crawl4ai_tools', None),
'crawl4ai_sync': ('.crawl4ai_tools', None),
'crawl4ai_extract_sync': ('.crawl4ai_tools', None),
'Crawl4AITools': ('.crawl4ai_tools', 'Crawl4AITools'),
'crawl4ai_tools': ('.crawl4ai_tools', None),
}
_instances = {} # Cache for class instances
@@ -184,6 +229,11 @@ def __getattr__(name: str) -> Any:
module_path, class_name = TOOL_MAPPINGS[name]
# Return class itself (not instance) for ClaudeMemoryTool, TavilyTools, YouTools, ExaTools, Crawl4AITools
if name in ('ClaudeMemoryTool', 'TavilyTools', 'YouTools', 'ExaTools', 'Crawl4AITools'):
module = import_module(module_path, __package__)
return getattr(module, class_name)
if class_name is None:
# Direct function import
module = import_module(module_path, __package__)
@@ -197,11 +247,18 @@ def __getattr__(name: str) -> Any:
'insert_document', 'insert_documents', 'find_documents', 'update_document', 'delete_document',
'create_vector_index', 'vector_search', 'store_with_embedding', 'text_search', 'get_stats', 'connect_mongodb',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial'
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
'exa', 'exa_search', 'exa_search_contents', 'exa_find_similar', 'exa_answer',
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
'crawl4ai_sync', 'crawl4ai_extract_sync'
]:
return getattr(module, name)
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools']:
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,529 @@
"""
Claude Memory Tool - File-based persistent memory for Claude models.
This module implements Anthropic's Memory Tool API (beta) which enables Claude
to store and retrieve information across conversations through a memory file directory.
The memory tool operates client-side - you control where and how the data is stored.
Beta Header: context-management-2025-06-27
Tool Type: memory_20250818
Supported Models:
- Claude Sonnet 4.5 (claude-sonnet-4-5-20250929)
- Claude Sonnet 4 (claude-sonnet-4-20250514)
- Claude Haiku 4.5 (claude-haiku-4-5-20251001)
- Claude Opus 4.5 (claude-opus-4-5-20251101)
- Claude Opus 4.1 (claude-opus-4-1-20250805)
- Claude Opus 4 (claude-opus-4-20250514)
Reference: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/memory-tool
"""
import shutil
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Memory system prompt that Claude uses to manage memory
MEMORY_SYSTEM_PROMPT = """
IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE.
MEMORY PROTOCOL:
1. Use the `view` command of your `memory` tool to check for earlier progress.
2. ... (work on the task) ...
- As you make progress, record status / progress / thoughts etc in your memory.
ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk losing any progress that is not recorded in your memory directory.
When editing your memory folder, always try to keep its content up-to-date, coherent and organized. You can rename or delete files that are no longer relevant. Do not create new files unless necessary.
"""
class ClaudeMemoryTool:
"""
File-based memory tool for Claude models.
Implements Anthropic's Memory Tool API (beta). Claude autonomously decides
what to store and retrieve from the memory directory.
Storage: {base_path}/memories/
Commands:
- view: Show directory contents or file contents with optional line ranges
- create: Create or overwrite a file
- str_replace: Replace text in a file
- insert: Insert text at a specific line
- delete: Delete a file or directory
- rename: Rename or move a file/directory
Example:
```python
from praisonaiagents import Agent
from praisonaiagents.tools import ClaudeMemoryTool
# Use default storage
agent = Agent(
name="Assistant",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=True
)
# Or with custom storage path
memory = ClaudeMemoryTool(base_path="./my_project/memory")
agent = Agent(
name="Assistant",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=memory
)
```
Security:
- All paths are validated to prevent directory traversal attacks
- Paths must start with /memories
- Symlinks are not followed outside the memory directory
"""
# Beta header required for memory tool
BETA_HEADER = "context-management-2025-06-27"
# Tool definition for API requests
TOOL_DEFINITION = {
"type": "memory_20250818",
"name": "memory"
}
def __init__(
self,
base_path: Optional[str] = None,
user_id: Optional[str] = None,
max_file_size: int = 1024 * 1024, # 1MB default
max_files: int = 100
):
"""
Initialize the Claude Memory Tool.
Args:
base_path: Base directory for memory storage.
Defaults to .praison/claude_memory/{user_id}
user_id: User identifier for user-specific memory.
Defaults to "default"
max_file_size: Maximum size per file in bytes (default 1MB)
max_files: Maximum number of files allowed (default 100)
"""
self.user_id = user_id or "default"
if base_path:
self.base_path = Path(base_path)
else:
self.base_path = Path(f".praison/claude_memory/{self.user_id}")
self.memory_root = self.base_path / "memories"
self.max_file_size = max_file_size
self.max_files = max_files
# Create memory directory
self.memory_root.mkdir(parents=True, exist_ok=True)
logger.debug(f"ClaudeMemoryTool initialized at {self.memory_root}")
def _validate_path(self, path: str) -> Path:
"""
Validate and resolve memory paths securely.
Args:
path: Path string starting with /memories
Returns:
Resolved Path object within memory directory
Raises:
ValueError: If path is invalid or attempts directory traversal
"""
if not path:
raise ValueError("Path cannot be empty")
if not path.startswith("/memories"):
raise ValueError(f"Path must start with /memories, got: {path}")
# Remove /memories prefix and leading slashes
relative_path = path[len("/memories"):].lstrip("/")
# Resolve the full path
if relative_path:
full_path = self.memory_root / relative_path
else:
full_path = self.memory_root
# Security check: ensure resolved path is within memory_root
try:
resolved = full_path.resolve()
resolved.relative_to(self.memory_root.resolve())
except ValueError as e:
raise ValueError(f"Path {path} would escape /memories directory") from e
# Check for suspicious patterns
if ".." in path or "~" in path:
raise ValueError(f"Invalid path pattern in: {path}")
return full_path
def execute(self, command: str, **kwargs) -> str:
"""
Execute a memory command.
Args:
command: Command name (view, create, str_replace, insert, delete, rename)
**kwargs: Command-specific arguments
Returns:
Result string from the command
Raises:
ValueError: If command is unknown
"""
commands = {
"view": self.view,
"create": self.create,
"str_replace": self.str_replace,
"insert": self.insert,
"delete": self.delete,
"rename": self.rename
}
if command not in commands:
raise ValueError(f"Unknown command: {command}. Valid commands: {list(commands.keys())}")
return commands[command](**kwargs)
def view(self, path: str, view_range: Optional[List[int]] = None) -> str:
"""
Show directory contents or file contents with optional line ranges.
Args:
path: Path to view (e.g., "/memories" or "/memories/notes.txt")
view_range: Optional [start_line, end_line] for file viewing.
Use -1 for end_line to read to end of file.
Returns:
Directory listing or file contents with line numbers
Raises:
RuntimeError: If path cannot be read
"""
full_path = self._validate_path(path)
if full_path.is_dir():
# List directory contents
items: List[str] = []
try:
for item in sorted(full_path.iterdir()):
# Skip hidden files
if item.name.startswith("."):
continue
# Add trailing slash for directories
items.append(f"{item.name}/" if item.is_dir() else item.name)
result = f"Directory: {path}\n"
if items:
result += "\n".join([f"- {item}" for item in items])
else:
result += "(empty directory)"
return result
except Exception as e:
raise RuntimeError(f"Cannot read directory {path}: {e}") from e
elif full_path.is_file():
# Read file contents
try:
content = full_path.read_text(encoding="utf-8")
lines = content.splitlines()
# Apply view range if specified
if view_range:
start_line = max(1, view_range[0]) - 1 # Convert to 0-indexed
end_line = len(lines) if view_range[1] == -1 else view_range[1]
lines = lines[start_line:end_line]
start_num = start_line + 1
else:
start_num = 1
# Add line numbers
numbered_lines = [
f"{i + start_num:4d}: {line}"
for i, line in enumerate(lines)
]
return "\n".join(numbered_lines) if numbered_lines else "(empty file)"
except Exception as e:
raise RuntimeError(f"Cannot read file {path}: {e}") from e
else:
raise RuntimeError(f"Path not found: {path}")
def create(self, path: str, file_text: str) -> str:
"""
Create or overwrite a file.
Args:
path: Path for the new file (e.g., "/memories/notes.txt")
file_text: Content to write to the file
Returns:
Success message
Raises:
ValueError: If file would exceed size limit or max files reached
"""
full_path = self._validate_path(path)
# Check file size limit
if len(file_text.encode('utf-8')) > self.max_file_size:
raise ValueError(
f"File content exceeds maximum size of {self.max_file_size} bytes"
)
# Check max files limit (only for new files)
if not full_path.exists():
current_files = sum(1 for _ in self.memory_root.rglob("*") if _.is_file())
if current_files >= self.max_files:
raise ValueError(
f"Maximum number of files ({self.max_files}) reached. "
"Delete some files before creating new ones."
)
# Create parent directories if needed
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
full_path.write_text(file_text, encoding="utf-8")
return f"File created successfully at {path}"
def str_replace(self, path: str, old_str: str, new_str: str) -> str:
"""
Replace text in a file.
Args:
path: Path to the file
old_str: Text to find (must be unique in file)
new_str: Replacement text
Returns:
Success message
Raises:
FileNotFoundError: If file doesn't exist
ValueError: If old_str not found or appears multiple times
"""
full_path = self._validate_path(path)
if not full_path.is_file():
raise FileNotFoundError(f"File not found: {path}")
content = full_path.read_text(encoding="utf-8")
# Count occurrences
count = content.count(old_str)
if count == 0:
raise ValueError(f"Text not found in {path}")
elif count > 1:
raise ValueError(
f"Text appears {count} times in {path}. Must be unique for replacement."
)
# Replace and write
new_content = content.replace(old_str, new_str)
# Check size limit
if len(new_content.encode('utf-8')) > self.max_file_size:
raise ValueError(
f"Resulting file would exceed maximum size of {self.max_file_size} bytes"
)
full_path.write_text(new_content, encoding="utf-8")
return f"File {path} has been edited"
def insert(self, path: str, insert_line: int, insert_text: str) -> str:
"""
Insert text at a specific line.
Args:
path: Path to the file
insert_line: Line number to insert at (0-indexed)
insert_text: Text to insert
Returns:
Success message
Raises:
FileNotFoundError: If file doesn't exist
ValueError: If line number is invalid
"""
full_path = self._validate_path(path)
if not full_path.is_file():
raise FileNotFoundError(f"File not found: {path}")
lines = full_path.read_text(encoding="utf-8").splitlines()
# Validate line number
if insert_line < 0 or insert_line > len(lines):
raise ValueError(
f"Invalid insert_line {insert_line}. Must be 0-{len(lines)}"
)
# Insert the text (strip trailing newline to avoid double newlines)
lines.insert(insert_line, insert_text.rstrip("\n"))
# Reconstruct content
new_content = "\n".join(lines) + "\n"
# Check size limit
if len(new_content.encode('utf-8')) > self.max_file_size:
raise ValueError(
f"Resulting file would exceed maximum size of {self.max_file_size} bytes"
)
full_path.write_text(new_content, encoding="utf-8")
return f"Text inserted at line {insert_line} in {path}"
def delete(self, path: str) -> str:
"""
Delete a file or directory.
Args:
path: Path to delete
Returns:
Success message
Raises:
ValueError: If trying to delete /memories root
FileNotFoundError: If path doesn't exist
"""
# Prevent deleting the root memories directory
if path == "/memories" or path == "/memories/":
raise ValueError("Cannot delete the /memories directory itself")
full_path = self._validate_path(path)
if full_path.is_file():
full_path.unlink()
return f"File deleted: {path}"
elif full_path.is_dir():
shutil.rmtree(full_path)
return f"Directory deleted: {path}"
else:
raise FileNotFoundError(f"Path not found: {path}")
def rename(self, old_path: str, new_path: str) -> str:
"""
Rename or move a file/directory.
Args:
old_path: Current path
new_path: New path
Returns:
Success message
Raises:
FileNotFoundError: If source doesn't exist
ValueError: If destination already exists
"""
old_full_path = self._validate_path(old_path)
new_full_path = self._validate_path(new_path)
if not old_full_path.exists():
raise FileNotFoundError(f"Source path not found: {old_path}")
if new_full_path.exists():
raise ValueError(f"Destination already exists: {new_path}")
# Create parent directories if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Rename/move
old_full_path.rename(new_full_path)
return f"Renamed {old_path} to {new_path}"
def clear_all(self) -> str:
"""
Clear all memory files.
Returns:
Success message
"""
if self.memory_root.exists():
shutil.rmtree(self.memory_root)
self.memory_root.mkdir(parents=True, exist_ok=True)
return "All memory cleared"
def get_tool_definition(self) -> Dict[str, Any]:
"""
Get the tool definition for API requests.
Returns:
Tool definition dict for Anthropic API
"""
return self.TOOL_DEFINITION.copy()
def get_beta_header(self) -> str:
"""
Get the beta header value for API requests.
Returns:
Beta header string
"""
return self.BETA_HEADER
def get_system_prompt(self) -> str:
"""
Get the memory system prompt to append to agent instructions.
Returns:
Memory protocol system prompt
"""
return MEMORY_SYSTEM_PROMPT
def process_tool_call(self, tool_input: Dict[str, Any]) -> str:
"""
Process a memory tool call from Claude.
Args:
tool_input: The input dict from Claude's tool_use block
Contains 'command' and command-specific parameters
Returns:
Result string to return to Claude
Example:
```python
# Claude sends:
# {"command": "view", "path": "/memories"}
result = memory_tool.process_tool_call(tool_input)
```
"""
command = tool_input.get("command")
if not command:
return "Error: No command specified in tool input"
try:
# Extract command-specific parameters
params = {k: v for k, v in tool_input.items() if k != "command"}
return self.execute(command, **params)
except Exception as e:
return f"Error: {str(e)}"
def __repr__(self) -> str:
return f"ClaudeMemoryTool(base_path='{self.base_path}', user_id='{self.user_id}')"
@@ -0,0 +1,630 @@
"""Crawl4AI web crawling and extraction tools.
Crawl4AI provides async web crawling with JavaScript rendering, content extraction,
and LLM-based data extraction capabilities.
Usage:
from praisonaiagents.tools import crawl4ai, crawl4ai_extract
# Simple crawl
result = await crawl4ai("https://example.com")
print(result["markdown"])
# Extract structured data
result = await crawl4ai_extract(
"https://example.com/products",
schema={"name": "Products", "baseSelector": ".product", ...}
)
# Or use the class directly
from praisonaiagents.tools import Crawl4AITools
tools = Crawl4AITools()
result = await tools.crawl("https://example.com")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import asyncio
from importlib import util
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
"""Check if Crawl4AI is available.
Returns:
Tuple of (is_available, error_message)
"""
if util.find_spec("crawl4ai") is None:
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
return True, None
class Crawl4AITools:
"""Crawl4AI web crawling and extraction tools.
Provides methods for:
- crawl: Basic web crawling with markdown output
- crawl_many: Crawl multiple URLs concurrently
- extract_css: Extract data using CSS selectors
- extract_llm: Extract data using LLM
Example:
from praisonaiagents.tools import Crawl4AITools
tools = Crawl4AITools()
result = await tools.crawl("https://example.com")
print(result["markdown"])
"""
def __init__(
self,
headless: bool = True,
verbose: bool = False,
browser_type: str = "chromium"
):
"""Initialize Crawl4AITools.
Args:
headless: Run browser in headless mode (default True)
verbose: Enable verbose logging (default False)
browser_type: Browser type - "chromium", "firefox", or "webkit"
"""
self.headless = headless
self.verbose = verbose
self.browser_type = browser_type
self.logger = logging.getLogger(__name__)
self._crawler = None
async def _get_crawler(self):
"""Get or create AsyncWebCrawler instance."""
is_available, error = _check_crawl4ai_available()
if not is_available:
raise ImportError(error)
from crawl4ai import AsyncWebCrawler, BrowserConfig
if self._crawler is None:
browser_config = BrowserConfig(
headless=self.headless,
verbose=self.verbose,
browser_type=self.browser_type
)
self._crawler = AsyncWebCrawler(config=browser_config)
await self._crawler.start()
return self._crawler
async def close(self):
"""Close the crawler and release resources."""
if self._crawler is not None:
await self._crawler.close()
self._crawler = None
async def crawl(
self,
url: str,
word_count_threshold: int = 10,
css_selector: Optional[str] = None,
excluded_tags: Optional[List[str]] = None,
exclude_external_links: bool = False,
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
screenshot: bool = False,
pdf: bool = False,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Crawl a single URL and return content.
Args:
url: The URL to crawl
word_count_threshold: Minimum words per text block (default 10)
css_selector: CSS selector to focus on specific content
excluded_tags: HTML tags to exclude (e.g., ["nav", "footer"])
exclude_external_links: Remove external links from content
js_code: JavaScript code to execute before crawling
wait_for: Wait condition - "css:selector" or "js:() => boolean"
screenshot: Capture screenshot (base64)
pdf: Generate PDF
bypass_cache: Skip cache and fetch fresh content (default True)
Returns:
Dict with 'markdown', 'html', 'cleaned_html', 'links', 'media', etc.
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
config = CrawlerRunConfig(
word_count_threshold=word_count_threshold,
css_selector=css_selector,
excluded_tags=excluded_tags or [],
exclude_external_links=exclude_external_links,
js_code=js_code,
wait_for=wait_for,
screenshot=screenshot,
pdf=pdf,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"Crawl failed: {result.error_message}", "success": False}
response = {
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html,
"cleaned_html": result.cleaned_html,
"links": result.links,
"media": result.media,
"metadata": result.metadata
}
if hasattr(result.markdown, 'fit_markdown') and result.markdown.fit_markdown:
response["fit_markdown"] = result.markdown.fit_markdown
if screenshot and result.screenshot:
response["screenshot"] = result.screenshot
if pdf and result.pdf:
response["pdf"] = result.pdf
return response
except Exception as e:
self.logger.error(f"Crawl4AI crawl error: {e}")
return {"error": f"Crawl4AI crawl error: {str(e)}", "success": False}
async def crawl_many(
self,
urls: List[str],
word_count_threshold: int = 10,
css_selector: Optional[str] = None,
bypass_cache: bool = True,
stream: bool = False
) -> List[Dict[str, Any]]:
"""Crawl multiple URLs concurrently.
Args:
urls: List of URLs to crawl
word_count_threshold: Minimum words per text block
css_selector: CSS selector to focus on specific content
bypass_cache: Skip cache and fetch fresh content
stream: If True, returns async generator
Returns:
List of crawl results
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
config = CrawlerRunConfig(
word_count_threshold=word_count_threshold,
css_selector=css_selector,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
stream=stream
)
results = []
if stream:
async for result in await crawler.arun_many(urls, config=config):
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
else:
raw_results = await crawler.arun_many(urls, config=config)
for result in raw_results:
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
return results
except Exception as e:
self.logger.error(f"Crawl4AI crawl_many error: {e}")
return [{"error": f"Crawl4AI crawl_many error: {str(e)}", "success": False}]
async def extract_css(
self,
url: str,
schema: Dict[str, Any],
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Extract structured data using CSS selectors.
Args:
url: The URL to crawl (or "raw://<html>" for raw HTML)
schema: Extraction schema with baseSelector and fields
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
bypass_cache: Skip cache
Returns:
Dict with 'data' containing extracted items
Example schema:
{
"name": "Products",
"baseSelector": "div.product",
"fields": [
{"name": "title", "selector": "h2", "type": "text"},
{"name": "price", "selector": ".price", "type": "text"},
{"name": "link", "selector": "a", "type": "attribute", "attribute": "href"}
]
}
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
extraction_strategy = JsonCssExtractionStrategy(schema, verbose=self.verbose)
config = CrawlerRunConfig(
extraction_strategy=extraction_strategy,
js_code=js_code,
wait_for=wait_for,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"Extraction failed: {result.error_message}", "success": False}
import json
try:
data = json.loads(result.extracted_content) if result.extracted_content else []
except json.JSONDecodeError:
data = result.extracted_content
return {
"success": True,
"url": result.url,
"data": data,
"count": len(data) if isinstance(data, list) else 1
}
except Exception as e:
self.logger.error(f"Crawl4AI extract_css error: {e}")
return {"error": f"Crawl4AI extract_css error: {str(e)}", "success": False}
async def extract_llm(
self,
url: str,
instruction: str,
schema: Optional[Dict[str, Any]] = None,
provider: str = "openai/gpt-4o-mini",
api_token: Optional[str] = None,
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Extract structured data using LLM.
Args:
url: The URL to crawl
instruction: Instructions for the LLM on what to extract
schema: Optional Pydantic-style JSON schema for structured output
provider: LLM provider string (e.g., "openai/gpt-4o-mini", "ollama/llama3.3")
api_token: API token for the provider (uses env var if not provided)
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
bypass_cache: Skip cache
Returns:
Dict with 'data' containing extracted content
"""
try:
crawler = await self._get_crawler()
import os
from crawl4ai import CrawlerRunConfig, CacheMode, LLMConfig
from crawl4ai.extraction_strategy import LLMExtractionStrategy
# Get API token from env if not provided
if api_token is None and "openai" in provider.lower():
api_token = os.environ.get("OPENAI_API_KEY")
llm_config = LLMConfig(provider=provider, api_token=api_token)
extraction_strategy = LLMExtractionStrategy(
llm_config=llm_config,
schema=schema,
extraction_type="schema" if schema else "block",
instruction=instruction,
verbose=self.verbose
)
config = CrawlerRunConfig(
extraction_strategy=extraction_strategy,
js_code=js_code,
wait_for=wait_for,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"LLM extraction failed: {result.error_message}", "success": False}
import json
try:
data = json.loads(result.extracted_content) if result.extracted_content else None
except json.JSONDecodeError:
data = result.extracted_content
return {
"success": True,
"url": result.url,
"data": data
}
except Exception as e:
self.logger.error(f"Crawl4AI extract_llm error: {e}")
return {"error": f"Crawl4AI extract_llm error: {str(e)}", "success": False}
# Standalone async functions for easy access
async def crawl4ai(
url: str,
css_selector: Optional[str] = None,
js_code: Optional[str] = None,
wait_for: Optional[str] = None,
screenshot: bool = False
) -> Dict[str, Any]:
"""Crawl a URL and return markdown content.
Args:
url: The URL to crawl
css_selector: CSS selector to focus on specific content
js_code: JavaScript to execute before crawling
wait_for: Wait condition - "css:selector" or "js:() => boolean"
screenshot: Capture screenshot
Returns:
Dict with 'markdown', 'html', 'links', 'media', etc.
Example:
result = await crawl4ai("https://example.com")
print(result["markdown"])
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
result = await tools.crawl(
url=url,
css_selector=css_selector,
js_code=js_code,
wait_for=wait_for,
screenshot=screenshot
)
return result
finally:
await tools.close()
async def crawl4ai_many(
urls: List[str],
css_selector: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Crawl multiple URLs concurrently.
Args:
urls: List of URLs to crawl
css_selector: CSS selector to focus on specific content
Returns:
List of crawl results
Example:
results = await crawl4ai_many(["https://example.com", "https://example.org"])
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return [{"error": error, "success": False}]
tools = Crawl4AITools()
try:
return await tools.crawl_many(urls=urls, css_selector=css_selector)
finally:
await tools.close()
async def crawl4ai_extract(
url: str,
schema: Dict[str, Any],
js_code: Optional[str] = None,
wait_for: Optional[str] = None
) -> Dict[str, Any]:
"""Extract structured data using CSS selectors.
Args:
url: The URL to crawl
schema: Extraction schema with baseSelector and fields
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
Returns:
Dict with 'data' containing extracted items
Example:
schema = {
"name": "Products",
"baseSelector": "div.product",
"fields": [
{"name": "title", "selector": "h2", "type": "text"},
{"name": "price", "selector": ".price", "type": "text"}
]
}
result = await crawl4ai_extract("https://example.com/products", schema)
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
return await tools.extract_css(
url=url,
schema=schema,
js_code=js_code,
wait_for=wait_for
)
finally:
await tools.close()
async def crawl4ai_llm_extract(
url: str,
instruction: str,
schema: Optional[Dict[str, Any]] = None,
provider: str = "openai/gpt-4o-mini"
) -> Dict[str, Any]:
"""Extract data using LLM.
Args:
url: The URL to crawl
instruction: Instructions for the LLM
schema: Optional JSON schema for structured output
provider: LLM provider (default "openai/gpt-4o-mini")
Returns:
Dict with 'data' containing extracted content
Example:
result = await crawl4ai_llm_extract(
"https://example.com/about",
instruction="Extract company name, founding year, and description"
)
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
return await tools.extract_llm(
url=url,
instruction=instruction,
schema=schema,
provider=provider
)
finally:
await tools.close()
# Synchronous wrappers for convenience
def crawl4ai_sync(
url: str,
css_selector: Optional[str] = None,
js_code: Optional[str] = None,
wait_for: Optional[str] = None
) -> Dict[str, Any]:
"""Synchronous version of crawl4ai.
Args:
url: The URL to crawl
css_selector: CSS selector to focus on specific content
js_code: JavaScript to execute
wait_for: Wait condition
Returns:
Dict with crawl results
"""
return asyncio.get_event_loop().run_until_complete(
crawl4ai(url, css_selector, js_code, wait_for)
)
def crawl4ai_extract_sync(
url: str,
schema: Dict[str, Any],
js_code: Optional[str] = None
) -> Dict[str, Any]:
"""Synchronous version of crawl4ai_extract.
Args:
url: The URL to crawl
schema: Extraction schema
js_code: JavaScript to execute
Returns:
Dict with extracted data
"""
return asyncio.get_event_loop().run_until_complete(
crawl4ai_extract(url, schema, js_code)
)
if __name__ == "__main__":
# Example usage
async def main():
print("=" * 60)
print("Crawl4AI Tools - Example Usage")
print("=" * 60)
is_available, error = _check_crawl4ai_available()
if not is_available:
print(f"\nCrawl4AI is not available: {error}")
print("\nTo use Crawl4AI tools:")
print("1. Install the package: pip install crawl4ai")
print("2. Run setup: crawl4ai-setup")
else:
print("Crawl4AI is available!")
# Example crawl
print("\n1. Basic Crawl")
print("-" * 30)
result = await crawl4ai("https://example.com")
if result.get("success"):
print(f"URL: {result['url']}")
print(f"Markdown length: {len(result.get('markdown', ''))}")
print(f"Links found: {len(result.get('links', {}).get('internal', []))}")
else:
print(f"Error: {result.get('error')}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
asyncio.run(main())
@@ -0,0 +1,741 @@
"""Exa search and research tools.
Exa provides AI-powered web search, content retrieval, and research capabilities.
This module requires the EXA_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import exa_search, exa_search_contents
# Search the web
results = exa_search("AI startups")
# Search with contents
results = exa_search_contents("AI in healthcare", text=True)
# Or use the class directly
from praisonaiagents.tools import ExaTools
exa = ExaTools()
results = exa.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_exa_available() -> tuple[bool, Optional[str]]:
"""Check if Exa is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if exa_py package is installed
if util.find_spec("exa_py") is None:
return False, "exa_py package is not installed. Install it with: pip install exa_py"
# Check if API key is set
if not os.environ.get("EXA_API_KEY"):
return False, "EXA_API_KEY environment variable is not set. Please set it to use Exa tools."
return True, None
class ExaTools:
"""Exa search and research tools.
Provides methods for:
- search: Basic web search
- search_and_contents: Search with full text/highlights
- find_similar: Find similar pages to a URL
- find_similar_and_contents: Find similar with content
- answer: Get AI-generated answers with citations
Example:
from praisonaiagents.tools import ExaTools
exa = ExaTools()
results = exa.search("AI startups", num_results=5)
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize ExaTools.
Args:
api_key: Exa API key. If not provided, uses EXA_API_KEY env var.
"""
self.api_key = api_key or os.environ.get("EXA_API_KEY")
self._client = None
self.logger = logging.getLogger(__name__)
def _get_client(self):
"""Get or create Exa client."""
if self._client is None:
is_available, error = _check_exa_available()
if not is_available:
raise ImportError(error)
from exa_py import Exa
self._client = Exa(self.api_key)
return self._client
def search(
self,
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
include_text: Optional[List[str]] = None,
exclude_text: Optional[List[str]] = None,
additional_queries: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Perform an Exa search.
Args:
query: The search query
num_results: Number of results to return (default 10, max 100)
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category - company, research paper, news, linkedin profile,
github, tweet, movie, song, personal site, pdf, financial report
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
include_text: Strings that must be present in results
exclude_text: Strings that must not be present in results
additional_queries: Additional query variations for deep search
Returns:
Dict with 'results' list and optional 'autopromptString'
"""
try:
client = self._get_client()
kwargs = {
"query": query,
"num_results": num_results
}
if type:
kwargs["type"] = type
if category:
kwargs["category"] = category
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if include_text:
kwargs["include_text"] = include_text
if exclude_text:
kwargs["exclude_text"] = exclude_text
if additional_queries:
kwargs["additional_queries"] = additional_queries
response = client.search(**kwargs)
return {
"results": [
{
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
for r in response.results
],
"autopromptString": getattr(response, "autoprompt_string", None),
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa search error: {e}")
return {"error": f"Exa search error: {str(e)}"}
def search_and_contents(
self,
query: str,
text: Union[bool, Dict] = True,
highlights: Union[bool, Dict] = False,
summary: Optional[Dict] = None,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
include_text: Optional[List[str]] = None,
exclude_text: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Search and retrieve content from results.
Args:
query: The search query
text: Include full text content (True or TextContentsOptions dict)
highlights: Include highlights (True or HighlightsContentsOptions dict)
summary: Summary options with optional schema for structured output
num_results: Number of results to return
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category filter
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
include_text: Strings that must be present in results
exclude_text: Strings that must not be present in results
Returns:
Dict with 'results' list containing text/highlights
"""
try:
client = self._get_client()
kwargs = {
"query": query,
"num_results": num_results
}
if text:
kwargs["text"] = text
if highlights:
kwargs["highlights"] = highlights
if summary:
kwargs["summary"] = summary
if type:
kwargs["type"] = type
if category:
kwargs["category"] = category
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if include_text:
kwargs["include_text"] = include_text
if exclude_text:
kwargs["exclude_text"] = exclude_text
response = client.search_and_contents(**kwargs)
results = []
for r in response.results:
result = {
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
if hasattr(r, "text") and r.text:
result["text"] = r.text
if hasattr(r, "highlights") and r.highlights:
result["highlights"] = r.highlights
if hasattr(r, "highlight_scores") and r.highlight_scores:
result["highlight_scores"] = r.highlight_scores
if hasattr(r, "summary") and r.summary:
result["summary"] = r.summary
results.append(result)
return {
"results": results,
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa search_and_contents error: {e}")
return {"error": f"Exa search_and_contents error: {str(e)}"}
def find_similar(
self,
url: str,
num_results: int = 10,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
exclude_source_domain: bool = False,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find similar pages to a given URL.
Args:
url: The URL to find similar pages for
num_results: Number of results to return
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
exclude_source_domain: Exclude results from the same domain
category: Data category filter
Returns:
Dict with 'results' list of similar pages
"""
try:
client = self._get_client()
kwargs = {
"url": url,
"num_results": num_results
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if exclude_source_domain:
kwargs["exclude_source_domain"] = exclude_source_domain
if category:
kwargs["category"] = category
response = client.find_similar(**kwargs)
return {
"results": [
{
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
for r in response.results
],
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa find_similar error: {e}")
return {"error": f"Exa find_similar error: {str(e)}"}
def find_similar_and_contents(
self,
url: str,
text: Union[bool, Dict] = True,
highlights: Union[bool, Dict] = False,
num_results: int = 10,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
exclude_source_domain: bool = False,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find similar pages with content.
Args:
url: The URL to find similar pages for
text: Include full text content
highlights: Include highlights
num_results: Number of results to return
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
exclude_source_domain: Exclude results from the same domain
category: Data category filter
Returns:
Dict with 'results' list containing text/highlights
"""
try:
client = self._get_client()
kwargs = {
"url": url,
"num_results": num_results
}
if text:
kwargs["text"] = text
if highlights:
kwargs["highlights"] = highlights
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if exclude_source_domain:
kwargs["exclude_source_domain"] = exclude_source_domain
if category:
kwargs["category"] = category
response = client.find_similar_and_contents(**kwargs)
results = []
for r in response.results:
result = {
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
if hasattr(r, "text") and r.text:
result["text"] = r.text
if hasattr(r, "highlights") and r.highlights:
result["highlights"] = r.highlights
if hasattr(r, "highlight_scores") and r.highlight_scores:
result["highlight_scores"] = r.highlight_scores
results.append(result)
return {
"results": results,
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa find_similar_and_contents error: {e}")
return {"error": f"Exa find_similar_and_contents error: {str(e)}"}
def answer(
self,
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Generate an answer using Exa's search and LLM capabilities.
Args:
query: The question to answer
text: If True, include full text of citations
Returns:
Dict with 'answer' and 'citations'
"""
try:
client = self._get_client()
response = client.answer(query=query, text=text)
citations = []
for c in response.citations:
citation = {
"id": c.id,
"url": c.url,
"title": getattr(c, "title", None),
"published_date": getattr(c, "published_date", None),
"author": getattr(c, "author", None)
}
if text and hasattr(c, "text") and c.text:
citation["text"] = c.text
citations.append(citation)
return {
"answer": response.answer,
"citations": citations
}
except Exception as e:
self.logger.error(f"Exa answer error: {e}")
return {"error": f"Exa answer error: {str(e)}"}
# Standalone functions for easy access
def exa_search(
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None
) -> Dict[str, Any]:
"""Search the web using Exa.
Args:
query: The search query
num_results: Number of results (default 10)
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category filter
include_domains: Domains to include
exclude_domains: Domains to exclude
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
Returns:
Dict with search results or error
Example:
results = exa_search("AI startups", num_results=5)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.search(
query=query,
num_results=num_results,
type=type,
category=category,
include_domains=include_domains,
exclude_domains=exclude_domains,
start_published_date=start_published_date,
end_published_date=end_published_date
)
def exa_search_contents(
query: str,
text: bool = True,
highlights: bool = False,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Search and get content from results.
Args:
query: The search query
text: Include full text content
highlights: Include highlights
num_results: Number of results
type: Search type
category: Data category filter
include_domains: Domains to include
exclude_domains: Domains to exclude
Returns:
Dict with results containing text/highlights
Example:
results = exa_search_contents("AI in healthcare", text=True, highlights=True)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.search_and_contents(
query=query,
text=text,
highlights=highlights,
num_results=num_results,
type=type,
category=category,
include_domains=include_domains,
exclude_domains=exclude_domains
)
def exa_find_similar(
url: str,
num_results: int = 10,
exclude_source_domain: bool = True,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find pages similar to a given URL.
Args:
url: The URL to find similar pages for
num_results: Number of results
exclude_source_domain: Exclude same domain results
category: Data category filter
Returns:
Dict with similar results
Example:
results = exa_find_similar("https://openai.com", num_results=5)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.find_similar(
url=url,
num_results=num_results,
exclude_source_domain=exclude_source_domain,
category=category
)
def exa_answer(
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Get an AI-generated answer with citations.
Args:
query: The question to answer
text: Include full text of citations
Returns:
Dict with 'answer' and 'citations'
Example:
result = exa_answer("What is the capital of France?")
print(result["answer"])
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.answer(query=query, text=text)
# Async versions
async def exa_search_async(
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Async version of exa_search.
Args:
query: The search query
num_results: Number of results
type: Search type
category: Data category filter
Returns:
Dict with search results
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_search(query, num_results, type, category)
)
async def exa_search_contents_async(
query: str,
text: bool = True,
highlights: bool = False,
num_results: int = 10
) -> Dict[str, Any]:
"""Async version of exa_search_contents.
Args:
query: The search query
text: Include full text
highlights: Include highlights
num_results: Number of results
Returns:
Dict with results containing content
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_search_contents(query, text, highlights, num_results)
)
async def exa_answer_async(
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Async version of exa_answer.
Args:
query: The question to answer
text: Include citation text
Returns:
Dict with answer and citations
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_answer(query, text)
)
# Alias for simple usage
exa = exa_search
if __name__ == "__main__":
# Example usage
print("=" * 60)
print("Exa Tools - Example Usage")
print("=" * 60)
is_available, error = _check_exa_available()
if not is_available:
print(f"\nExa is not available: {error}")
print("\nTo use Exa tools:")
print("1. Install the package: pip install exa_py")
print("2. Set environment variable: export EXA_API_KEY=your_api_key")
else:
print("Exa SDK is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = exa_search("Latest AI developments 2024", num_results=3)
if "error" not in results:
print(f"Found {len(results.get('results', []))} results")
for r in results.get("results", [])[:3]:
print(f" - {r.get('title', 'No title')[:50]}...")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
@@ -0,0 +1,591 @@
"""Tavily search and extraction tools.
Tavily provides AI-powered web search, content extraction, and crawling capabilities.
This module requires the TAVILY_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import tavily_search, tavily_extract
# Search the web
results = tavily_search("AI news 2024")
# Extract content from URLs
content = tavily_extract("https://example.com")
# Or use the class directly
from praisonaiagents.tools import TavilyTools
tavily = TavilyTools()
results = tavily.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_tavily_available() -> tuple[bool, Optional[str]]:
"""Check if Tavily is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if tavily package is installed
if util.find_spec("tavily") is None:
return False, "Tavily package is not installed. Please install it using: pip install tavily-python"
# Check if API key is set
api_key = os.environ.get("TAVILY_API_KEY")
if not api_key:
return False, "TAVILY_API_KEY environment variable is not set. Please set it to use Tavily tools."
return True, None
class TavilyTools:
"""Comprehensive tools for web search and content extraction using Tavily API.
Tavily provides AI-powered search capabilities optimized for LLM applications.
Features:
- Web search with advanced filtering and ranking
- Content extraction from URLs
- Website crawling with intelligent navigation
- Site mapping
Requires:
- tavily-python package: pip install tavily-python
- TAVILY_API_KEY environment variable
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize TavilyTools.
Args:
api_key: Optional API key. If not provided, uses TAVILY_API_KEY env var.
"""
self._api_key = api_key
self._client = None
self._async_client = None
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("TAVILY_API_KEY")
def _get_client(self):
"""Get or create synchronous Tavily client."""
if self._client is None:
is_available, error = _check_tavily_available()
if not is_available:
return None, error
from tavily import TavilyClient
api_key = self._get_api_key()
if not api_key:
return None, "TAVILY_API_KEY not set"
self._client = TavilyClient(api_key=api_key)
return self._client, None
def _get_async_client(self):
"""Get or create asynchronous Tavily client."""
if self._async_client is None:
is_available, error = _check_tavily_available()
if not is_available:
return None, error
from tavily import AsyncTavilyClient
api_key = self._get_api_key()
if not api_key:
return None, "TAVILY_API_KEY not set"
self._async_client = AsyncTavilyClient(api_key=api_key)
return self._async_client, None
def search(
self,
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using Tavily's AI-powered search.
Args:
query: The search query
search_depth: "basic" or "advanced" (advanced uses more credits but better results)
topic: "general", "news", or "finance"
max_results: Maximum number of results (1-20)
include_answer: Include LLM-generated answer based on results
include_raw_content: Include cleaned HTML content of each result
include_images: Include query-related images
include_domains: List of domains to include (max 300)
exclude_domains: List of domains to exclude (max 150)
time_range: Filter by time - "day", "week", "month", "year" or "d", "w", "m", "y"
Returns:
Dict containing search results with keys:
- results: List of search results with title, url, content, score
- query: The search query
- response_time: Time taken for search
- answer: (optional) LLM-generated answer if include_answer=True
- images: (optional) List of image URLs if include_images=True
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"query": query,
"search_depth": search_depth,
"topic": topic,
"max_results": max_results,
"include_answer": include_answer,
"include_raw_content": include_raw_content,
"include_images": include_images,
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if time_range:
kwargs["time_range"] = time_range
response = client.search(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def extract(
self,
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Extract content from one or more URLs.
Args:
urls: Single URL or list of URLs (max 20)
include_images: Include extracted images
extract_depth: "basic" or "advanced" (advanced retrieves more data)
Returns:
Dict containing:
- results: List of successful extractions with url, raw_content
- failed_results: List of URLs that failed with error messages
- response_time: Time taken for extraction
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
response = client.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
return response
except Exception as e:
error_msg = f"Tavily extract error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def crawl(
self,
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Crawl a website starting from a base URL.
Args:
url: The root URL to begin crawling
max_depth: Maximum depth from base URL (default: 1)
max_breadth: Max links to follow per page (default: 20)
limit: Total links to process before stopping (default: 50)
instructions: Natural language instructions for the crawler
include_images: Extract image URLs from pages
extract_depth: "basic" or "advanced"
Returns:
Dict containing:
- base_url: The starting URL
- results: List of crawled pages with url, raw_content, images
- response_time: Time taken for crawl
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"url": url,
"max_depth": max_depth,
"max_breadth": max_breadth,
"limit": limit,
"include_images": include_images,
"extract_depth": extract_depth,
}
if instructions:
kwargs["instructions"] = instructions
response = client.crawl(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily crawl error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def map(
self,
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a sitemap starting from a base URL.
Args:
url: The root URL to begin mapping
max_depth: Maximum depth from base URL (default: 1)
max_breadth: Max links to follow per page (default: 20)
limit: Total links to process before stopping (default: 50)
instructions: Natural language instructions for the mapper
Returns:
Dict containing:
- base_url: The starting URL
- results: List of discovered URLs
- response_time: Time taken for mapping
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"url": url,
"max_depth": max_depth,
"max_breadth": max_breadth,
"limit": limit,
}
if instructions:
kwargs["instructions"] = instructions
response = client.map(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily map error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
# Async methods
async def asearch(
self,
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Async version of search. See search() for documentation."""
client, error = self._get_async_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"query": query,
"search_depth": search_depth,
"topic": topic,
"max_results": max_results,
"include_answer": include_answer,
"include_raw_content": include_raw_content,
"include_images": include_images,
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if time_range:
kwargs["time_range"] = time_range
response = await client.search(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily async search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
async def aextract(
self,
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Async version of extract. See extract() for documentation."""
client, error = self._get_async_client()
if error:
logging.error(error)
return {"error": error}
try:
response = await client.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
return response
except Exception as e:
error_msg = f"Tavily async extract error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
# Standalone functions for direct import
def tavily_search(
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using Tavily's AI-powered search.
This is a convenience function that creates a TavilyTools instance.
For repeated calls, consider using TavilyTools class directly.
Args:
query: The search query
search_depth: "basic" or "advanced"
topic: "general", "news", or "finance"
max_results: Maximum number of results (1-20)
include_answer: Include LLM-generated answer
include_raw_content: Include cleaned HTML content
include_images: Include query-related images
include_domains: List of domains to include
exclude_domains: List of domains to exclude
time_range: Filter by time - "day", "week", "month", "year"
Returns:
Dict containing search results
"""
tools = TavilyTools()
return tools.search(
query=query,
search_depth=search_depth,
topic=topic,
max_results=max_results,
include_answer=include_answer,
include_raw_content=include_raw_content,
include_images=include_images,
include_domains=include_domains,
exclude_domains=exclude_domains,
time_range=time_range,
)
def tavily_extract(
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Extract content from one or more URLs using Tavily.
Args:
urls: Single URL or list of URLs (max 20)
include_images: Include extracted images
extract_depth: "basic" or "advanced"
Returns:
Dict containing extraction results
"""
tools = TavilyTools()
return tools.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
def tavily_crawl(
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Crawl a website starting from a base URL using Tavily.
Args:
url: The root URL to begin crawling
max_depth: Maximum depth from base URL
max_breadth: Max links to follow per page
limit: Total links to process before stopping
instructions: Natural language instructions for the crawler
include_images: Extract image URLs from pages
extract_depth: "basic" or "advanced"
Returns:
Dict containing crawl results
"""
tools = TavilyTools()
return tools.crawl(
url=url,
max_depth=max_depth,
max_breadth=max_breadth,
limit=limit,
instructions=instructions,
include_images=include_images,
extract_depth=extract_depth,
)
def tavily_map(
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a sitemap starting from a base URL using Tavily.
Args:
url: The root URL to begin mapping
max_depth: Maximum depth from base URL
max_breadth: Max links to follow per page
limit: Total links to process before stopping
instructions: Natural language instructions for the mapper
Returns:
Dict containing map results with discovered URLs
"""
tools = TavilyTools()
return tools.map(
url=url,
max_depth=max_depth,
max_breadth=max_breadth,
limit=limit,
instructions=instructions,
)
# Async standalone functions
async def tavily_search_async(
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Async version of tavily_search. See tavily_search() for documentation."""
tools = TavilyTools()
return await tools.asearch(
query=query,
search_depth=search_depth,
topic=topic,
max_results=max_results,
include_answer=include_answer,
include_raw_content=include_raw_content,
include_images=include_images,
include_domains=include_domains,
exclude_domains=exclude_domains,
time_range=time_range,
)
async def tavily_extract_async(
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Async version of tavily_extract. See tavily_extract() for documentation."""
tools = TavilyTools()
return await tools.aextract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
if __name__ == "__main__":
# Example usage
print("\n==================================================")
print("Tavily Tools Demonstration")
print("==================================================\n")
# Check if API key is available
is_available, error = _check_tavily_available()
if not is_available:
print(f"Error: {error}")
print("\nTo use Tavily tools:")
print("1. Install: pip install tavily-python")
print("2. Set environment variable: export TAVILY_API_KEY=your_api_key")
else:
print("Tavily is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = tavily_search("Latest AI developments 2024", max_results=3)
if "error" not in results:
print(f"Found {len(results.get('results', []))} results")
for r in results.get("results", [])[:3]:
print(f" - {r.get('title', 'No title')}")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n==================================================")
print("Demonstration Complete")
print("==================================================")
# Alias for simple usage: from praisonaiagents.tools import tavily
tavily = tavily_search
@@ -0,0 +1,533 @@
"""You.com search and content tools.
You.com provides AI-powered web search, news search, content extraction, and image search.
This module requires the YDC_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import ydc_search, ydc_news, ydc_contents
# Search the web (unified web + news)
results = ydc_search("AI news 2024")
# Get live news
news = ydc_news("technology trends")
# Extract content from URLs
content = ydc_contents(["https://example.com"])
# Or use the class directly
from praisonaiagents.tools import YouTools
you = YouTools()
results = you.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_youdotcom_available() -> tuple[bool, Optional[str]]:
"""Check if You.com SDK is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if youdotcom package is installed
if util.find_spec("youdotcom") is None:
return False, "youdotcom package is not installed. Please install it using: pip install youdotcom"
# Check if API key is set
api_key = os.environ.get("YDC_API_KEY")
if not api_key:
return False, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
return True, None
class YouTools:
"""Comprehensive tools for web search and content using You.com API.
You.com provides AI-powered search capabilities optimized for LLM applications.
Features:
- Unified web and news search with LLM-ready snippets
- Live news search from authoritative sources
- Content extraction from URLs (HTML/Markdown)
- Image search
- Advanced search operators support
- Geographic and language targeting
- Freshness controls
Requires:
- youdotcom package: pip install youdotcom
- YDC_API_KEY environment variable
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize YouTools.
Args:
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
"""
self._api_key = api_key
self._client = None
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("YDC_API_KEY")
def _get_client(self):
"""Get or create You.com client."""
if self._client is None:
is_available, error = _check_youdotcom_available()
if not is_available:
return None, error
from youdotcom import You
api_key = self._get_api_key()
if not api_key:
return None, "YDC_API_KEY not set"
self._client = You(api_key_auth=api_key)
return self._client, None
def search(
self,
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
Returns both web and news results in a single call with LLM-ready snippets.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
country: ISO country code (e.g., "US", "GB", "FR")
language: BCP 47 language code (e.g., "EN", "JP", "DE")
offset: Pagination offset (0-9), results = offset * count
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing:
- results: {web: [...], news: [...]}
- metadata: {query, search_uuid, latency}
Search Operators:
- site:domain.com - Search within specific domain
- filetype:pdf - Filter by file type
- +term - Must include term
- -term - Exclude term
- AND, OR, NOT - Boolean operators
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"query": query, "count": count, "offset": offset}
if freshness:
kwargs["freshness"] = freshness
if country:
kwargs["country"] = country
if language:
kwargs["language"] = language
if safesearch:
kwargs["safesearch"] = safesearch
if livecrawl:
kwargs["livecrawl"] = livecrawl
if livecrawl_formats:
kwargs["livecrawl_formats"] = livecrawl_formats
response = client.search.unified(**kwargs)
# Convert response to dict if it's a model object
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'to_dict'):
return response.to_dict()
elif hasattr(response, '__dict__'):
return self._convert_response(response)
return response
except Exception as e:
error_msg = f"You.com search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def _convert_response(self, response) -> Dict[str, Any]:
"""Convert SDK response object to dictionary."""
result = {}
if hasattr(response, 'results'):
results = response.results
result['results'] = {}
if hasattr(results, 'web') and results.web:
result['results']['web'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'snippets': getattr(r, 'snippets', []),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
'favicon_url': getattr(r, 'favicon_url', None),
'authors': getattr(r, 'authors', []),
}
for r in results.web
]
if hasattr(results, 'news') and results.news:
result['results']['news'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
}
for r in results.news
]
if hasattr(response, 'metadata'):
meta = response.metadata
result['metadata'] = {
'query': getattr(meta, 'query', ''),
'search_uuid': getattr(meta, 'search_uuid', ''),
'latency': getattr(meta, 'latency', 0),
}
return result
def get_contents(
self,
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown" (default: markdown)
Returns:
Dict containing array of results with:
- url: The fetched URL
- title: Page title
- html: HTML content (if format="html")
- markdown: Markdown content (if format="markdown")
- metadata: {site_name, favicon_url}
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if isinstance(urls, str):
urls = [urls]
# Use the contents endpoint
response = client.search.contents(urls=urls, format=format)
# Convert response
if hasattr(response, 'model_dump'):
return {"results": response.model_dump()}
elif isinstance(response, list):
return {"results": [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'html': getattr(r, 'html', None),
'markdown': getattr(r, 'markdown', None),
'metadata': {
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
}
}
for r in response
]}
return {"results": response}
except Exception as e:
error_msg = f"You.com contents error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def live_news(
self,
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Note: This API may require early access. Contact api@you.com for access.
Args:
query: News search query
count: Maximum number of news results to return
Returns:
Dict containing:
- news: {query, results: [...], metadata}
- Each result has: title, description, url, page_age, source_name, thumbnail
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"q": query}
if count:
kwargs["count"] = count
# Try to access live news endpoint
if hasattr(client, 'livenews'):
response = client.livenews.get(**kwargs)
else:
# Fallback: use unified search with news focus
return self.search(query=query, count=count or 10)
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'news'):
news = response.news
return {
'news': {
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
'results': [
{
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'url': getattr(r, 'url', ''),
'page_age': getattr(r, 'page_age', None),
'age': getattr(r, 'age', None),
'source_name': getattr(r, 'source_name', None),
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
}
for r in getattr(news, 'results', [])
],
'metadata': {
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
}
}
}
return response
except Exception as e:
error_msg = f"You.com live news error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def images(
self,
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Note: This API may require early access. Contact api@you.com for access.
Args:
query: Image search query
Returns:
Dict containing:
- images: {results: [...]}
- metadata: {query, search_uuid}
- Each result has: title, page_url, image_url
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if hasattr(client, 'images'):
response = client.images.get(q=query)
else:
return {"error": "Image search not available in this SDK version"}
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'images'):
images = response.images
return {
'images': {
'results': [
{
'title': getattr(r, 'title', ''),
'page_url': getattr(r, 'page_url', ''),
'image_url': getattr(r, 'image_url', ''),
}
for r in getattr(images, 'results', [])
]
},
'metadata': {
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
}
}
return response
except Exception as e:
error_msg = f"You.com images error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
if self._client is not None:
# Close client if it has a close method
if hasattr(self._client, 'close'):
self._client.close()
elif hasattr(self._client, '__exit__'):
self._client.__exit__(exc_type, exc_val, exc_tb)
return False
# Standalone functions for direct import
def ydc_search(
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
This is a convenience function that creates a YouTools instance.
For repeated calls, consider using YouTools class directly.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year"
country: ISO country code (e.g., "US", "GB")
language: BCP 47 language code (e.g., "EN", "JP")
offset: Pagination offset (0-9)
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing search results with web and news sections
"""
tools = YouTools()
return tools.search(
query=query,
count=count,
freshness=freshness,
country=country,
language=language,
offset=offset,
safesearch=safesearch,
livecrawl=livecrawl,
livecrawl_formats=livecrawl_formats,
)
def ydc_contents(
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown"
Returns:
Dict containing extracted content for each URL
"""
tools = YouTools()
return tools.get_contents(urls=urls, format=format)
def ydc_news(
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Args:
query: News search query
count: Maximum number of results
Returns:
Dict containing news results
"""
tools = YouTools()
return tools.live_news(query=query, count=count)
def ydc_images(
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Args:
query: Image search query
Returns:
Dict containing image results
"""
tools = YouTools()
return tools.images(query=query)
if __name__ == "__main__":
# Example usage
print("\n" + "=" * 60)
print("You.com Tools Demonstration")
print("=" * 60 + "\n")
# Check if API key is available
is_available, error = _check_youdotcom_available()
if not is_available:
print(f"Error: {error}")
print("\nTo use You.com tools:")
print("1. Install: pip install youdotcom")
print("2. Set environment variable: export YDC_API_KEY=your_api_key")
else:
print("You.com SDK is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = ydc_search("Latest AI developments 2024", count=3)
if "error" not in results:
web_results = results.get('results', {}).get('web', [])
print(f"Found {len(web_results)} web results")
for r in web_results[:3]:
print(f" - {r.get('title', 'No title')[:50]}...")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
# Alias for simple usage: from praisonaiagents.tools import ydc
ydc = ydc_search
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.175"
version = "0.0.188"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
@@ -0,0 +1,16 @@
from praisonaiagents import Agent
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents"
agent = Agent(
name="AI Assistant",
role="Research and Writing Specialist",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
planning_reasoning=True
)
result = agent.start("Research AI trends in 2025 and write a 2 sentence summary")
print(result)
@@ -0,0 +1,18 @@
from praisonaiagents import Agent
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents"
agent = Agent(
name="AI Assistant",
role="Research and Writing Specialist",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
verbose=False # Quiet mode
)
result = agent.start("Research AI trends in 2025 and write a 2 sentence summary")
print("\n=== FINAL RESULT ===")
print(result)
@@ -0,0 +1,62 @@
from praisonaiagents import Agent
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents, edge AI"
print("=" * 60)
print("TEST 1: Planning ONLY (no tools, no reasoning)")
print("=" * 60)
agent1 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
verbose=False
)
result1 = agent1.start("Write 2 sentences about AI in healthcare")
print(f"\nResult: {result1[:200]}...")
print("\n" + "=" * 60)
print("TEST 2: Planning + Tools (no reasoning)")
print("=" * 60)
agent2 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
verbose=False
)
result2 = agent2.start("Write 2 sentences about AI trends")
print(f"\nResult: {result2[:200]}...")
print("\n" + "=" * 60)
print("TEST 3: Planning + Tools + Reasoning")
print("=" * 60)
agent3 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
verbose=False
)
result3 = agent3.start("Write 2 sentences about AI trends")
print(f"\nResult: {result3[:200]}...")
print("\n" + "=" * 60)
print("TEST 4: No Planning (direct execution)")
print("=" * 60)
agent4 = Agent(
name="Assistant",
llm="gpt-4o-mini",
verbose=False
)
result4 = agent4.start("Write 2 sentences about AI trends")
print(f"\nResult: {result4[:200]}...")
print("\n" + "=" * 60)
print("COMPARISON COMPLETE")
print("=" * 60)
@@ -0,0 +1,183 @@
"""
Complete Planning Mode Demo: Plan First, Then Execute Todo Items One by One.
This demonstrates the full workflow:
1. Create a plan with the PlanningAgent
2. Generate todo list from the plan
3. Execute each todo item sequentially with the appropriate agent
4. Update progress as items are completed
Run with: python test_planning_execution.py
"""
from praisonaiagents import Agent, Task
from praisonaiagents.planning import PlanningAgent, TodoList
def print_separator(title: str):
print("\n" + "=" * 70)
print(f" {title}")
print("=" * 70 + "\n")
def print_progress(todo_list: TodoList):
"""Print current progress."""
completed = len(todo_list.completed)
total = len(todo_list.items)
progress = todo_list.progress * 100
bar_length = 30
filled = int(bar_length * todo_list.progress)
bar = "█" * filled + "░" * (bar_length - filled)
print(f"\n📊 Progress: [{bar}] {progress:.0f}% ({completed}/{total} completed)\n")
def main():
print_separator("🎯 PLANNING MODE: PLAN FIRST, EXECUTE TODO ITEMS")
# ==========================================================================
# STEP 1: Create Agents
# ==========================================================================
print("📦 Creating agents...")
researcher = Agent(
name="Researcher",
role="Research Analyst",
goal="Research topics thoroughly and provide accurate information",
backstory="Expert researcher with access to vast knowledge bases",
verbose=True
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Write clear, engaging, and well-structured content",
backstory="Professional writer skilled in creating compelling narratives",
verbose=True
)
print("✅ Agents created: Researcher, Writer\n")
# ==========================================================================
# STEP 2: Create Plan using PlanningAgent
# ==========================================================================
print_separator("📋 PHASE 1: CREATING PLAN")
planner = PlanningAgent(
llm="gpt-4o-mini",
read_only=True,
verbose=1
)
request = "Write a short article about the top 3 benefits of meditation for mental health"
print(f"📝 Request: {request}\n")
print("🤔 Planning agent is analyzing and creating a plan...\n")
plan = planner.create_plan_sync(
request=request,
agents=[researcher, writer],
context="Keep it concise - each section should be 2-3 sentences max"
)
print("\n✅ Plan created successfully!")
print(f" Plan Name: {plan.name}")
print(f" Total Steps: {len(plan.steps)}")
# ==========================================================================
# STEP 3: Display the Plan
# ==========================================================================
print_separator("📄 GENERATED PLAN")
print(plan.to_markdown())
# ==========================================================================
# STEP 4: Create Todo List from Plan
# ==========================================================================
print_separator("📝 PHASE 2: TODO LIST CREATED")
todo_list = TodoList.from_plan(plan)
print(todo_list.to_markdown())
print_progress(todo_list)
# ==========================================================================
# STEP 5: Execute Each Todo Item One by One
# ==========================================================================
print_separator("🚀 PHASE 3: EXECUTING TODO ITEMS")
# Map agent names to agent instances
agent_map = {
"Researcher": researcher,
"Writer": writer
}
results = {}
for i, item in enumerate(todo_list.items):
print(f"\n{'─' * 70}")
print(f"📌 TODO ITEM {i + 1}/{len(todo_list.items)}")
print(f"{'─' * 70}")
print(f" Description: {item.description}")
print(f" Agent: {item.agent or 'Auto-assign'}")
print(f" Status: {item.status}")
if item.dependencies:
print(f" Dependencies: {', '.join(item.dependencies)}")
# Mark as in progress
todo_list.start(item.id)
print(f"\n ⏳ Starting execution...")
# Get the appropriate agent
agent_name = item.agent or "Researcher"
agent = agent_map.get(agent_name, researcher)
# Build context from previous results
context = ""
for dep_id in item.dependencies:
if dep_id in results:
context += f"\nPrevious result ({dep_id}):\n{results[dep_id]}\n"
# Create task description
task_description = item.description
if context:
task_description += f"\n\nContext from previous steps:{context}"
# Execute with the agent
try:
result = agent.chat(task_description)
results[item.id] = result
# Mark as completed
todo_list.complete(item.id)
print(f"\n ✅ Completed!")
print(f"\n 📄 Result Preview:")
print(" " + "-" * 50)
preview = str(result)[:500] + "..." if len(str(result)) > 500 else str(result)
for line in preview.split('\n'):
print(f" {line}")
except Exception as e:
print(f"\n ❌ Error: {e}")
results[item.id] = f"Error: {e}"
print_progress(todo_list)
# ==========================================================================
# STEP 6: Final Summary
# ==========================================================================
print_separator("🎉 EXECUTION COMPLETE")
print("📋 Final Todo List Status:")
print(todo_list.to_markdown())
print("\n📊 Final Results Summary:")
print("-" * 50)
for item in todo_list.items:
status_icon = "✅" if item.status == "completed" else "❌"
print(f"{status_icon} {item.description[:60]}...")
print(f"\n🏆 All {len(todo_list.completed)} tasks completed successfully!")
if __name__ == "__main__":
main()
@@ -0,0 +1,22 @@
from praisonaiagents import Agent, Task, PraisonAIAgents
def search_web(query: str) -> str:
return f"Results: AI trends include LLMs, multimodal AI, autonomous agents"
researcher = Agent(name="Researcher", role="Research Analyst", llm="gpt-4o-mini")
writer = Agent(name="Writer", role="Content Writer", llm="gpt-4o-mini")
task1 = Task(description="Research AI trends in 2025", agent=researcher)
task2 = Task(description="Write a 2 sentence summary", agent=writer)
agents = PraisonAIAgents(
agents=[researcher, writer],
tasks=[task1, task2],
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
auto_approve_plan=True
)
result = agents.start()
print(result)
@@ -0,0 +1,28 @@
from praisonaiagents import Agent, Task, PraisonAIAgents
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents"
agent = Agent(
name="AI Assistant",
role="Research and Writing Specialist",
llm="gpt-4o-mini",
instructions="Research AI trends in 2025 and write a 2 sentence summary"
)
task = Task(
description="Research AI trends in 2025 and write a 2 sentence summary",
agent=agent
)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
auto_approve_plan=True
)
result = agents.start()
print(result)
@@ -0,0 +1,34 @@
from praisonaiagents import Agent
from praisonaiagents.planning import PlanningAgent
def search_web(query: str) -> str:
return f"Results for '{query}': LLMs, multimodal AI, autonomous agents"
researcher = Agent(name="Researcher", role="Research Analyst", llm="gpt-4o-mini")
writer = Agent(name="Writer", role="Content Writer", llm="gpt-4o-mini")
print("\n=== TEST 1: Basic Planning (NO tools) ===")
planner1 = PlanningAgent(llm="gpt-4o-mini")
plan1 = planner1.create_plan_sync(
request="Write about AI trends 2025",
agents=[researcher, writer]
)
print(f"Plan: {plan1.name}, Steps: {len(plan1.steps)}")
print("\n=== TEST 2: Planning WITH tools ===")
planner2 = PlanningAgent(llm="gpt-4o-mini", tools=[search_web])
plan2 = planner2.create_plan_sync(
request="Write about AI trends 2025",
agents=[researcher, writer]
)
print(f"Plan: {plan2.name}, Steps: {len(plan2.steps)}")
print("\n=== TEST 3: Planning WITH tools + reasoning ===")
planner3 = PlanningAgent(llm="gpt-4o-mini", tools=[search_web], reasoning=True)
plan3 = planner3.create_plan_sync(
request="Write about AI trends 2025",
agents=[researcher, writer]
)
print(f"Plan: {plan3.name}, Steps: {len(plan3.steps)}")
print("\n✅ Done!")
@@ -0,0 +1,460 @@
"""
Tests for Claude Memory Tool.
Claude Memory Tool enables Claude to store and retrieve information across
conversations through a memory file directory.
Reference: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/memory-tool
"""
import os
import shutil
import tempfile
import pytest
from pathlib import Path
class TestClaudeMemoryToolBasics:
"""Tests for ClaudeMemoryTool basic functionality."""
def test_import(self):
"""Test that ClaudeMemoryTool can be imported."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
assert ClaudeMemoryTool is not None
def test_init_default(self):
"""Test default initialization."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
assert tool.memory_root.exists()
assert tool.user_id == "default"
def test_init_with_user_id(self):
"""Test initialization with custom user_id."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir, user_id="test_user")
assert tool.user_id == "test_user"
def test_get_tool_definition(self):
"""Test tool definition for API."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
definition = tool.get_tool_definition()
assert definition["type"] == "memory_20250818"
assert definition["name"] == "memory"
def test_get_beta_header(self):
"""Test beta header value."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
header = tool.get_beta_header()
assert header == "context-management-2025-06-27"
class TestClaudeMemoryToolPathValidation:
"""Tests for path validation and security."""
def test_valid_path(self):
"""Test valid path validation."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
path = tool._validate_path("/memories")
assert path == tool.memory_root
def test_valid_subpath(self):
"""Test valid subpath validation."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
path = tool._validate_path("/memories/notes.txt")
assert path == tool.memory_root / "notes.txt"
def test_invalid_path_no_memories_prefix(self):
"""Test that paths without /memories prefix are rejected."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
with pytest.raises(ValueError, match="must start with /memories"):
tool._validate_path("/etc/passwd")
def test_directory_traversal_blocked(self):
"""Test that directory traversal is blocked."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
with pytest.raises(ValueError):
tool._validate_path("/memories/../../../etc/passwd")
class TestClaudeMemoryToolCommands:
"""Tests for memory tool commands."""
def test_view_empty_directory(self):
"""Test viewing empty directory."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
result = tool.view(path="/memories")
assert "Directory: /memories" in result
assert "(empty directory)" in result
def test_create_and_view_file(self):
"""Test creating and viewing a file."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create file
result = tool.create(path="/memories/notes.txt", file_text="Hello, World!")
assert "created successfully" in result
# View file
result = tool.view(path="/memories/notes.txt")
assert "Hello, World!" in result
def test_view_directory_with_files(self):
"""Test viewing directory with files."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create files
tool.create(path="/memories/file1.txt", file_text="Content 1")
tool.create(path="/memories/file2.txt", file_text="Content 2")
# View directory
result = tool.view(path="/memories")
assert "file1.txt" in result
assert "file2.txt" in result
def test_str_replace(self):
"""Test string replacement in file."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create file
tool.create(path="/memories/notes.txt", file_text="Hello, World!")
# Replace text
result = tool.str_replace(
path="/memories/notes.txt",
old_str="World",
new_str="Claude"
)
assert "edited" in result
# Verify replacement
result = tool.view(path="/memories/notes.txt")
assert "Hello, Claude!" in result
def test_str_replace_not_found(self):
"""Test string replacement when text not found."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
tool.create(path="/memories/notes.txt", file_text="Hello, World!")
with pytest.raises(ValueError, match="not found"):
tool.str_replace(
path="/memories/notes.txt",
old_str="Nonexistent",
new_str="Replacement"
)
def test_insert(self):
"""Test inserting text at line."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create file with multiple lines
tool.create(path="/memories/notes.txt", file_text="Line 1\nLine 2\nLine 3")
# Insert at line 1
result = tool.insert(
path="/memories/notes.txt",
insert_line=1,
insert_text="Inserted Line"
)
assert "inserted" in result
# Verify insertion
result = tool.view(path="/memories/notes.txt")
assert "Inserted Line" in result
def test_delete_file(self):
"""Test deleting a file."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create and delete file
tool.create(path="/memories/notes.txt", file_text="Content")
result = tool.delete(path="/memories/notes.txt")
assert "deleted" in result
# Verify deletion
with pytest.raises(RuntimeError):
tool.view(path="/memories/notes.txt")
def test_delete_root_blocked(self):
"""Test that deleting /memories root is blocked."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
with pytest.raises(ValueError, match="Cannot delete"):
tool.delete(path="/memories")
def test_rename(self):
"""Test renaming a file."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create file
tool.create(path="/memories/old.txt", file_text="Content")
# Rename
result = tool.rename(
old_path="/memories/old.txt",
new_path="/memories/new.txt"
)
assert "Renamed" in result
# Verify rename
with pytest.raises(RuntimeError):
tool.view(path="/memories/old.txt")
result = tool.view(path="/memories/new.txt")
assert "Content" in result
def test_clear_all(self):
"""Test clearing all memory."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
# Create files
tool.create(path="/memories/file1.txt", file_text="Content 1")
tool.create(path="/memories/file2.txt", file_text="Content 2")
# Clear all
result = tool.clear_all()
assert "cleared" in result
# Verify empty
result = tool.view(path="/memories")
assert "(empty directory)" in result
class TestClaudeMemoryToolProcessToolCall:
"""Tests for process_tool_call method."""
def test_process_view_command(self):
"""Test processing view command."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
result = tool.process_tool_call({
"command": "view",
"path": "/memories"
})
assert "Directory: /memories" in result
def test_process_create_command(self):
"""Test processing create command."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
result = tool.process_tool_call({
"command": "create",
"path": "/memories/test.txt",
"file_text": "Test content"
})
assert "created" in result
def test_process_unknown_command(self):
"""Test processing unknown command."""
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
with tempfile.TemporaryDirectory() as tmpdir:
tool = ClaudeMemoryTool(base_path=tmpdir)
result = tool.process_tool_call({
"command": "unknown_command",
"path": "/memories"
})
assert "Error" in result
class TestLLMClaudeMemoryIntegration:
"""Tests for LLM class integration with Claude Memory Tool."""
def test_llm_init_with_claude_memory_true(self):
"""Test LLM initialization with claude_memory=True."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="anthropic/claude-sonnet-4-20250514", claude_memory=True)
assert llm.claude_memory == True
def test_llm_init_with_claude_memory_false(self):
"""Test LLM initialization with claude_memory=False."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="anthropic/claude-sonnet-4-20250514", claude_memory=False)
assert llm.claude_memory == False
def test_llm_init_without_claude_memory(self):
"""Test LLM initialization without claude_memory parameter."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o-mini")
assert llm.claude_memory is None
def test_llm_supports_claude_memory_anthropic(self):
"""Test _supports_claude_memory for Anthropic model."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="anthropic/claude-sonnet-4-20250514", claude_memory=True)
assert llm._supports_claude_memory() == True
def test_llm_supports_claude_memory_non_anthropic(self):
"""Test _supports_claude_memory for non-Anthropic model."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o-mini", claude_memory=True)
assert llm._supports_claude_memory() == False
def test_llm_get_claude_memory_tool(self):
"""Test _get_claude_memory_tool method."""
from praisonaiagents.llm.llm import LLM
from praisonaiagents.tools.claude_memory_tool import ClaudeMemoryTool
llm = LLM(model="anthropic/claude-sonnet-4-20250514", claude_memory=True)
tool = llm._get_claude_memory_tool()
assert isinstance(tool, ClaudeMemoryTool)
def test_llm_is_memory_tool_call(self):
"""Test _is_memory_tool_call method."""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="anthropic/claude-sonnet-4-20250514", claude_memory=True)
assert llm._is_memory_tool_call("memory") == True
assert llm._is_memory_tool_call("other_tool") == False
class TestAgentClaudeMemoryIntegration:
"""Tests for Agent class integration with Claude Memory Tool."""
def test_agent_init_with_claude_memory_true(self):
"""Test Agent initialization with claude_memory=True."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=True
)
assert agent.claude_memory == True
def test_agent_init_with_claude_memory_false(self):
"""Test Agent initialization with claude_memory=False."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=False
)
assert agent.claude_memory == False
def test_agent_init_without_claude_memory(self):
"""Test Agent initialization without claude_memory parameter."""
from praisonaiagents import Agent
agent = Agent(name="Test Agent")
assert agent.claude_memory is None
def test_agent_passes_claude_memory_to_llm(self):
"""Test that Agent passes claude_memory to LLM instance."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=True
)
if hasattr(agent, 'llm_instance') and agent.llm_instance:
assert agent.llm_instance.claude_memory == True
class TestClaudeMemoryToolIntegration:
"""Integration tests for Claude Memory Tool (requires API key)."""
@pytest.mark.skip(reason="Requires ANTHROPIC_API_KEY environment variable")
def test_claude_memory_with_anthropic(self):
"""Test Claude memory with actual Anthropic API."""
import os
from praisonaiagents import Agent
# Skip if no API key
if not os.getenv("ANTHROPIC_API_KEY"):
pytest.skip("ANTHROPIC_API_KEY not set")
agent = Agent(
name="Memory Test Agent",
instructions="You are a helpful assistant with memory capabilities.",
llm="anthropic/claude-sonnet-4-20250514",
claude_memory=True,
verbose=False
)
# First call - should check memory
result1 = agent.start("Remember that my favorite color is blue.")
assert result1 is not None
# Second call - should recall from memory
result2 = agent.start("What is my favorite color?")
assert result2 is not None
@@ -0,0 +1,829 @@
"""
Tests for FileMemory - Zero-dependency file-based memory system.
"""
import tempfile
import pytest
class TestFileMemoryBasics:
"""Tests for FileMemory basic functionality."""
def test_import(self):
"""Test that FileMemory can be imported."""
from praisonaiagents.memory.file_memory import FileMemory
assert FileMemory is not None
def test_init_default(self):
"""Test default initialization."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
assert memory.user_id == "default"
assert memory.user_path.exists()
def test_init_with_user_id(self):
"""Test initialization with custom user_id."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir, user_id="test_user")
assert memory.user_id == "test_user"
assert "test_user" in str(memory.user_path)
def test_config_persistence(self):
"""Test that config is saved and loaded."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
# Create with custom config
memory1 = FileMemory(
base_path=tmpdir,
config={"short_term_limit": 50}
)
assert memory1.config["short_term_limit"] == 50
# Create new instance - should load saved config
memory2 = FileMemory(base_path=tmpdir)
assert memory2.config["short_term_limit"] == 50
class TestShortTermMemory:
"""Tests for short-term memory functionality."""
def test_add_short_term(self):
"""Test adding short-term memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
mem_id = memory.add_short_term("User prefers dark mode")
assert mem_id is not None
assert len(memory._short_term) == 1
def test_get_short_term(self):
"""Test retrieving short-term memories."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("Memory 1")
memory.add_short_term("Memory 2")
memory.add_short_term("Memory 3")
# Get all
items = memory.get_short_term()
assert len(items) == 3
# Most recent first
assert items[0].content == "Memory 3"
# Get limited
items = memory.get_short_term(limit=2)
assert len(items) == 2
def test_short_term_rolling_buffer(self):
"""Test that short-term memory enforces limit."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(
base_path=tmpdir,
config={"short_term_limit": 5, "auto_promote": False}
)
# Add more than limit
for i in range(10):
memory.add_short_term(f"Memory {i}")
# Should only have last 5
assert len(memory._short_term) == 5
items = memory.get_short_term()
assert items[0].content == "Memory 9"
def test_short_term_persistence(self):
"""Test that short-term memory persists to file."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
# Add memory
memory1 = FileMemory(base_path=tmpdir)
memory1.add_short_term("Persistent memory")
# Create new instance - should load from file
memory2 = FileMemory(base_path=tmpdir)
items = memory2.get_short_term()
assert len(items) == 1
assert items[0].content == "Persistent memory"
class TestLongTermMemory:
"""Tests for long-term memory functionality."""
def test_add_long_term(self):
"""Test adding long-term memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
mem_id = memory.add_long_term("User's name is John", importance=0.9)
assert mem_id is not None
assert len(memory._long_term) == 1
def test_get_long_term_sorted_by_importance(self):
"""Test that long-term memories are sorted by importance."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_long_term("Low importance", importance=0.3)
memory.add_long_term("High importance", importance=0.9)
memory.add_long_term("Medium importance", importance=0.6)
items = memory.get_long_term()
assert items[0].content == "High importance"
assert items[1].content == "Medium importance"
assert items[2].content == "Low importance"
def test_auto_promote_to_long_term(self):
"""Test auto-promotion of high-importance short-term to long-term."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(
base_path=tmpdir,
config={
"short_term_limit": 3,
"auto_promote": True,
"importance_threshold": 0.7
}
)
# Add high importance items
memory.add_short_term("Important 1", importance=0.8)
memory.add_short_term("Important 2", importance=0.9)
memory.add_short_term("Low importance", importance=0.3)
# Trigger promotion by exceeding limit
memory.add_short_term("New item", importance=0.5)
# High importance items should be in long-term
long_term = memory.get_long_term()
assert len(long_term) >= 2
class TestEntityMemory:
"""Tests for entity memory functionality."""
def test_add_entity(self):
"""Test adding an entity."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
entity_id = memory.add_entity(
name="John",
entity_type="person",
attributes={"role": "developer", "company": "Acme"}
)
assert entity_id is not None
assert len(memory._entities) == 1
def test_get_entity(self):
"""Test retrieving an entity."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_entity("John", "person", {"role": "developer"})
entity = memory.get_entity("John")
assert entity is not None
assert entity.name == "John"
assert entity.entity_type == "person"
assert entity.attributes["role"] == "developer"
def test_update_entity(self):
"""Test updating an existing entity."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_entity("John", "person", {"role": "developer"})
memory.add_entity("John", "person", {"company": "Acme"})
entity = memory.get_entity("John")
assert entity.attributes["role"] == "developer"
assert entity.attributes["company"] == "Acme"
def test_get_all_entities(self):
"""Test getting all entities."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_entity("John", "person", {})
memory.add_entity("Acme", "organization", {})
memory.add_entity("New York", "place", {})
# All entities
all_entities = memory.get_all_entities()
assert len(all_entities) == 3
# Filter by type
people = memory.get_all_entities(entity_type="person")
assert len(people) == 1
assert people[0].name == "John"
class TestEpisodicMemory:
"""Tests for episodic memory functionality."""
def test_add_episodic(self):
"""Test adding episodic memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
mem_id = memory.add_episodic("Had a meeting about project X")
assert mem_id is not None
def test_get_episodic_by_date(self):
"""Test retrieving episodic memories by date."""
from praisonaiagents.memory.file_memory import FileMemory
from datetime import datetime
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
today = datetime.now().strftime("%Y-%m-%d")
memory.add_episodic("Today's event", date=today)
episodes = memory.get_episodic(date=today)
assert len(episodes) == 1
assert episodes[0].content == "Today's event"
def test_get_recent_episodic(self):
"""Test retrieving recent episodic memories."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_episodic("Event 1")
memory.add_episodic("Event 2")
episodes = memory.get_episodic(days_back=7)
assert len(episodes) == 2
class TestSearch:
"""Tests for search functionality."""
def test_search_short_term(self):
"""Test searching short-term memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("User prefers dark mode")
memory.add_short_term("User likes Python programming")
memory.add_short_term("Weather is sunny today")
results = memory.search("user", memory_types=["short_term"])
assert len(results) == 2
def test_search_all_types(self):
"""Test searching across all memory types."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("John mentioned Python")
memory.add_long_term("John is a developer")
memory.add_entity("John", "person", {"skill": "Python"})
results = memory.search("John")
assert len(results) >= 3
def test_search_relevance_scoring(self):
"""Test that search results are scored by relevance."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("Python is great", importance=0.5)
memory.add_short_term("I love Python programming", importance=0.8)
results = memory.search("Python")
assert len(results) == 2
# Higher importance should score higher
assert results[0]["score"] >= results[1]["score"]
class TestContext:
"""Tests for context building."""
def test_get_context_empty(self):
"""Test getting context with no memories."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
context = memory.get_context()
assert context == ""
def test_get_context_with_memories(self):
"""Test getting context with memories."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_long_term("User's name is John", importance=0.9)
memory.add_short_term("User asked about Python")
memory.add_entity("John", "person", {"role": "developer"})
context = memory.get_context()
assert "John" in context
assert "Important Facts" in context or "Known Entities" in context
def test_get_context_with_query(self):
"""Test getting focused context with query."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_long_term("User's name is John")
memory.add_long_term("User likes Python")
memory.add_long_term("Weather is sunny")
context = memory.get_context(query="John")
assert "John" in context
class TestUtilities:
"""Tests for utility methods."""
def test_clear_short_term(self):
"""Test clearing short-term memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("Memory 1")
memory.add_short_term("Memory 2")
memory.clear_short_term()
assert len(memory._short_term) == 0
def test_clear_all(self):
"""Test clearing all memory."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
memory.add_short_term("Short term")
memory.add_long_term("Long term")
memory.add_entity("John", "person", {})
memory.add_episodic("Episode")
memory.clear_all()
assert len(memory._short_term) == 0
assert len(memory._long_term) == 0
assert len(memory._entities) == 0
def test_get_stats(self):
"""Test getting memory statistics."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir, user_id="test_user")
memory.add_short_term("Short term")
memory.add_long_term("Long term")
memory.add_entity("John", "person", {})
stats = memory.get_stats()
assert stats["user_id"] == "test_user"
assert stats["short_term_count"] == 1
assert stats["long_term_count"] == 1
assert stats["entity_count"] == 1
def test_export_import(self):
"""Test exporting and importing memory data."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
# Create and populate memory
memory1 = FileMemory(base_path=tmpdir, user_id="user1")
memory1.add_short_term("Short term memory")
memory1.add_long_term("Long term memory")
memory1.add_entity("John", "person", {"role": "dev"})
# Export
exported = memory1.export()
# Import to new memory
memory2 = FileMemory(base_path=tmpdir, user_id="user2")
memory2.import_data(exported)
assert len(memory2._short_term) == 1
assert len(memory2._long_term) == 1
assert len(memory2._entities) == 1
class TestAgentIntegration:
"""Tests for Agent class integration with FileMemory."""
def test_agent_memory_true(self):
"""Test Agent with memory=True uses FileMemory."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
memory=True
)
assert agent.memory is True
assert agent._memory_instance is not None
def test_agent_memory_false(self):
"""Test Agent with memory=False has no memory."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
memory=False
)
assert agent._memory_instance is None
def test_agent_memory_file(self):
"""Test Agent with memory='file' uses FileMemory."""
from praisonaiagents import Agent
from praisonaiagents.memory.file_memory import FileMemory
agent = Agent(
name="Test Agent",
memory="file"
)
assert isinstance(agent._memory_instance, FileMemory)
def test_agent_store_memory(self):
"""Test Agent store_memory method."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
memory=True
)
agent.store_memory("User prefers dark mode", memory_type="short_term")
items = agent._memory_instance.get_short_term()
assert len(items) == 1
assert items[0].content == "User prefers dark mode"
def test_agent_get_memory_context(self):
"""Test Agent get_memory_context method."""
from praisonaiagents import Agent
agent = Agent(
name="Test Agent",
memory=True
)
agent.store_memory("User's name is John", memory_type="long_term", importance=0.9)
context = agent.get_memory_context()
assert "John" in context
class TestCreateMemoryFunction:
"""Tests for create_memory convenience function."""
def test_create_memory(self):
"""Test create_memory function."""
from praisonaiagents.memory import create_memory
with tempfile.TemporaryDirectory() as tmpdir:
memory = create_memory(user_id="test", base_path=tmpdir)
assert memory.user_id == "test"
memory.add_short_term("Test memory")
assert len(memory._short_term) == 1
class TestMemoryIntegrationWithOpenAI:
"""Integration tests for memory with OpenAI API (requires OPENAI_API_KEY)."""
def test_memory_injection_into_system_prompt(self):
"""Test that memory context is injected into system prompt and agent recalls it."""
import os
import shutil
# Skip if no API key
if not os.getenv("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
from praisonaiagents import Agent
test_user_id = "integration_test_user"
try:
# Create agent with memory enabled
agent = Agent(
name="Memory Test Agent",
role="Assistant",
goal="Help users and remember their information",
backstory="You are a helpful assistant with memory capabilities.",
# Uses default model (gpt-4o-mini via OPENAI_MODEL_NAME or fallback)
memory=True,
user_id=test_user_id,
verbose=False
)
# Store memories BEFORE the chat
agent.store_memory("User's favorite color is blue", memory_type="short_term")
agent.store_memory("User's name is Alice", memory_type="long_term", importance=0.95)
agent.store_memory("User works as a software engineer", memory_type="long_term", importance=0.9)
# Add entity
agent._memory_instance.add_entity("Alice", "person", {
"occupation": "software engineer",
"favorite_color": "blue"
})
# Ask agent about the user - should recall from memory
result = agent.start("What is my name and what do I do for work? Answer in one sentence.")
# Verify the response contains the remembered information
assert result is not None
result_lower = result.lower()
assert "alice" in result_lower, f"Expected 'alice' in response: {result}"
assert "software" in result_lower or "engineer" in result_lower, f"Expected occupation in response: {result}"
print(f"✅ Memory injection test passed! Response: {result}")
finally:
# Cleanup
memory_path = f".praison/memory/{test_user_id}"
if os.path.exists(memory_path):
shutil.rmtree(memory_path)
def test_memory_persistence_across_agent_instances(self):
"""Test that memory persists when creating new agent instances."""
import os
import shutil
# Skip if no API key
if not os.getenv("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
from praisonaiagents import Agent
test_user_id = "persistence_test_user"
try:
# First agent - store memories
agent1 = Agent(
name="Agent 1",
# Uses default model (gpt-4o-mini via OPENAI_MODEL_NAME or fallback)
memory=True,
user_id=test_user_id,
verbose=False
)
agent1.store_memory("User prefers Python over JavaScript", memory_type="long_term", importance=0.9)
agent1.store_memory("User is learning machine learning", memory_type="short_term")
# Verify memories stored
stats1 = agent1._memory_instance.get_stats()
assert stats1["long_term_count"] == 1
assert stats1["short_term_count"] == 1
# Second agent - same user_id, should load memories
agent2 = Agent(
name="Agent 2",
# Uses default model (gpt-4o-mini via OPENAI_MODEL_NAME or fallback)
memory=True,
user_id=test_user_id,
verbose=False
)
# Verify memories loaded
stats2 = agent2._memory_instance.get_stats()
assert stats2["long_term_count"] == 1, f"Expected 1 long-term memory, got {stats2}"
assert stats2["short_term_count"] == 1, f"Expected 1 short-term memory, got {stats2}"
# Ask about preferences - should recall from loaded memory
result = agent2.start("What programming language do I prefer? Answer in one word.")
assert result is not None
assert "python" in result.lower(), f"Expected 'python' in response: {result}"
print(f"✅ Memory persistence test passed! Response: {result}")
finally:
# Cleanup
memory_path = f".praison/memory/{test_user_id}"
if os.path.exists(memory_path):
shutil.rmtree(memory_path)
class TestMultiAgentMemorySharing:
"""Tests for memory sharing between multiple agents."""
def test_agents_share_memory_with_same_user_id(self):
"""Test that agents with same user_id share memory."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
user_id = "shared_user"
base_path = f"{tmpdir}/memory"
# Agent 1 stores memories
agent1 = Agent(
name="Agent1",
memory={"provider": "file", "user_id": user_id, "base_path": base_path},
verbose=False
)
agent1.store_memory("User prefers Python", memory_type="short_term")
agent1.store_memory("User name is Alice", memory_type="long_term", importance=0.9)
agent1._memory_instance.add_entity("Alice", "person", {"role": "developer"})
# Verify Agent1 stored memories
assert len(agent1._memory_instance.get_short_term()) == 1
assert len(agent1._memory_instance.get_long_term()) == 1
assert len(agent1._memory_instance.get_all_entities()) == 1
# Agent 2 with same user_id should load memories
agent2 = Agent(
name="Agent2",
memory={"provider": "file", "user_id": user_id, "base_path": base_path},
verbose=False
)
# Verify Agent2 loaded the same memories
assert len(agent2._memory_instance.get_short_term()) == 1
assert len(agent2._memory_instance.get_long_term()) == 1
assert len(agent2._memory_instance.get_all_entities()) == 1
# Verify content matches
assert agent2._memory_instance.get_short_term()[0].content == "User prefers Python"
assert agent2._memory_instance.get_long_term()[0].content == "User name is Alice"
assert agent2._memory_instance.get_entity("Alice") is not None
def test_agents_isolated_with_different_user_id(self):
"""Test that agents with different user_id have isolated memory."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
base_path = f"{tmpdir}/memory"
# Agent 1 with user_id "user1"
agent1 = Agent(
name="Agent1",
memory={"provider": "file", "user_id": "user1", "base_path": base_path},
verbose=False
)
agent1.store_memory("User1 data", memory_type="long_term", importance=0.9)
# Agent 2 with different user_id "user2"
agent2 = Agent(
name="Agent2",
memory={"provider": "file", "user_id": "user2", "base_path": base_path},
verbose=False
)
# Agent2 should have no memories (different user_id)
assert len(agent2._memory_instance.get_long_term()) == 0
assert len(agent2._memory_instance.get_short_term()) == 0
# Verify paths are different
assert agent1._memory_instance.user_path != agent2._memory_instance.user_path
def test_memory_context_injected_into_system_prompt(self):
"""Test that memory context is injected into system prompt."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
agent = Agent(
name="TestAgent",
memory={"provider": "file", "user_id": "test", "base_path": f"{tmpdir}/memory"},
verbose=False
)
# Store memories
agent.store_memory("User prefers dark mode", memory_type="short_term")
agent.store_memory("User name is John", memory_type="long_term", importance=0.9)
# Get memory context
context = agent.get_memory_context()
# Verify context contains the memories
assert "John" in context
assert "dark mode" in context
assert "Important Facts" in context or "Recent Context" in context
def test_memory_display_info(self):
"""Test that memory display info shows correct counts."""
from praisonaiagents import Agent
with tempfile.TemporaryDirectory() as tmpdir:
agent = Agent(
name="TestAgent",
memory={"provider": "file", "user_id": "test", "base_path": f"{tmpdir}/memory"},
verbose=True
)
# Store various memories
agent.store_memory("Short term 1", memory_type="short_term")
agent.store_memory("Short term 2", memory_type="short_term")
agent.store_memory("Long term 1", memory_type="long_term", importance=0.9)
agent._memory_instance.add_entity("Entity1", "person", {})
# Get stats
stats = agent._memory_instance.get_stats()
# Verify counts are as expected (at least the ones we added)
assert stats["short_term_count"] >= 2
assert stats["long_term_count"] >= 1
assert stats["entity_count"] >= 1
class TestMultiAgentMemoryIntegration:
"""Integration tests for multi-agent memory with OpenAI API."""
def test_multi_agent_memory_sharing_with_llm(self):
"""Test that multiple agents share memory and use it in LLM calls."""
import os
import shutil
# Skip if no API key
if not os.getenv("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
from praisonaiagents import Agent
test_user_id = "multi_agent_llm_test"
try:
# Agent 1 stores memories
agent1 = Agent(
name="Agent1",
memory=True,
user_id=test_user_id,
verbose=False
)
agent1.store_memory("User's favorite color is blue", memory_type="long_term", importance=0.9)
agent1.store_memory("User works as a teacher", memory_type="long_term", importance=0.85)
# Agent 2 with same user_id should recall memories
agent2 = Agent(
name="Agent2",
memory=True,
user_id=test_user_id,
verbose=False
)
# Verify memories loaded
stats = agent2._memory_instance.get_stats()
assert stats["long_term_count"] == 2
# Ask Agent2 about the user - should use loaded memories
result = agent2.start("What is my favorite color? Answer in one word.")
assert result is not None
assert "blue" in result.lower(), f"Expected 'blue' in response: {result}"
print(f"✅ Multi-agent memory sharing test passed! Response: {result}")
finally:
memory_path = f".praison/memory/{test_user_id}"
if os.path.exists(memory_path):
shutil.rmtree(memory_path)
@@ -0,0 +1,299 @@
"""Tests for MCP server wrapper - TDD approach.
These tests define the expected behavior of ToolsMCPServer class.
"""
import pytest
from unittest.mock import patch
class TestToolsMCPServerInit:
"""Test ToolsMCPServer initialization."""
def test_init_with_name(self):
"""Test server initializes with custom name."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer(name="my-tools")
assert server.name == "my-tools"
def test_init_with_default_name(self):
"""Test server initializes with default name."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
assert server.name == "praisonai-tools"
def test_init_with_tools_list(self):
"""Test server initializes with list of tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
def my_tool(x: str) -> str:
"""A test tool."""
return x
server = ToolsMCPServer(tools=[my_tool])
assert len(server.tools) == 1
def test_init_empty_tools(self):
"""Test server initializes with empty tools list."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
assert server.tools == []
class TestToolsMCPServerRegister:
"""Test tool registration."""
def test_register_single_tool(self):
"""Test registering a single tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str) -> str:
"""Search for information."""
return f"Results for {query}"
server.register_tool(search)
assert len(server.tools) == 1
assert server.tools[0].__name__ == "search"
def test_register_multiple_tools(self):
"""Test registering multiple tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tool(tool1)
server.register_tool(tool2)
assert len(server.tools) == 2
def test_register_tools_batch(self):
"""Test registering multiple tools at once."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tools([tool1, tool2])
assert len(server.tools) == 2
def test_register_async_tool(self):
"""Test registering an async tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
async def async_search(query: str) -> str:
"""Async search."""
return f"Results for {query}"
server.register_tool(async_search)
assert len(server.tools) == 1
class TestToolsMCPServerSchemas:
"""Test schema generation for registered tools."""
def test_get_tool_schemas(self):
"""Test getting schemas for all registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str, max_results: int = 10) -> str:
"""Search the web."""
return ""
server.register_tool(search)
schemas = server.get_tool_schemas()
assert len(schemas) == 1
assert schemas[0]["name"] == "search"
assert schemas[0]["description"] == "Search the web."
assert "query" in schemas[0]["inputSchema"]["properties"]
def test_get_tool_names(self):
"""Test getting list of tool names."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tools([tool1, tool2])
names = server.get_tool_names()
assert set(names) == {"tool1", "tool2"}
class TestToolsMCPServerExecution:
"""Test tool execution through MCP server."""
def test_execute_sync_tool(self):
"""Test executing a synchronous tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
server.register_tool(add)
result = server.execute_tool("add", {"a": 5, "b": 3})
assert result == 8
@pytest.mark.asyncio
async def test_execute_async_tool(self):
"""Test executing an async tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
async def async_add(a: int, b: int) -> int:
"""Add two numbers asynchronously."""
return a + b
server.register_tool(async_add)
result = await server.execute_tool_async("async_add", {"a": 5, "b": 3})
assert result == 8
def test_execute_nonexistent_tool(self):
"""Test executing a tool that doesn't exist."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
with pytest.raises(ValueError, match="Tool 'nonexistent' not found"):
server.execute_tool("nonexistent", {})
class TestToolsMCPServerFastMCP:
"""Test FastMCP integration."""
def test_get_fastmcp_instance(self):
"""Test getting FastMCP instance with registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer(name="test-server")
def my_tool(x: str) -> str:
"""A test tool."""
return x
server.register_tool(my_tool)
# Get FastMCP instance
mcp = server.get_fastmcp()
assert mcp is not None
assert mcp.name == "test-server"
def test_fastmcp_has_registered_tools(self):
"""Test that FastMCP instance has all registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str) -> str:
"""Search tool."""
return query
def calculate(x: int, y: int) -> int:
"""Calculate tool."""
return x + y
server.register_tools([search, calculate])
mcp = server.get_fastmcp()
# FastMCP should have both tools registered
# We can check by looking at the internal tool registry
assert mcp is not None
class TestLaunchToolsMCPServer:
"""Test the convenience function for launching MCP server."""
def test_launch_function_exists(self):
"""Test that launch_tools_mcp_server function exists."""
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
assert callable(launch_tools_mcp_server)
def test_launch_with_tool_names(self):
"""Test launching server with tool names from registry."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
# This should create a server with specified tools
# We'll mock the actual launch to avoid blocking
with patch.object(ToolsMCPServer, 'run', return_value=None):
# Just verify it doesn't raise
pass # Actual launch test will be in integration tests
def test_launch_with_custom_tools(self):
"""Test launching server with custom tool functions."""
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
def my_custom_tool(x: str) -> str:
"""Custom tool."""
return x
# Verify function accepts tools parameter
# Actual launch test will be in integration tests
assert callable(launch_tools_mcp_server)
class TestToolsMCPServerTransports:
"""Test different transport options."""
def test_stdio_transport_config(self):
"""Test stdio transport configuration."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
# Server should support stdio transport
assert hasattr(server, 'run_stdio') or hasattr(server, 'run')
def test_sse_transport_config(self):
"""Test SSE transport configuration."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
# Server should support SSE transport
assert hasattr(server, 'run_sse') or hasattr(server, 'run')
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,225 @@
"""Tests for MCP utility functions - TDD approach.
These tests define the expected behavior of mcp_utils module.
"""
import pytest
from typing import Optional, List, Dict, Any
class TestFunctionToMCPSchema:
"""Test function_to_mcp_schema() converts Python functions to MCP tool schemas."""
def test_simple_function_with_string_param(self):
"""Test conversion of simple function with string parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for {query}"
schema = function_to_mcp_schema(search)
assert schema["name"] == "search"
assert schema["description"] == "Search the web for information."
assert schema["inputSchema"]["type"] == "object"
assert "query" in schema["inputSchema"]["properties"]
assert schema["inputSchema"]["properties"]["query"]["type"] == "string"
assert "query" in schema["inputSchema"]["required"]
def test_function_with_multiple_params(self):
"""Test function with multiple parameters of different types."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, max_results: int, include_images: bool) -> Dict[str, Any]:
"""Search with options."""
return {}
schema = function_to_mcp_schema(search)
assert schema["name"] == "search"
props = schema["inputSchema"]["properties"]
assert props["query"]["type"] == "string"
assert props["max_results"]["type"] == "integer"
assert props["include_images"]["type"] == "boolean"
assert set(schema["inputSchema"]["required"]) == {"query", "max_results", "include_images"}
def test_function_with_optional_params(self):
"""Test function with optional parameters (defaults)."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, max_results: int = 10, include_images: bool = False) -> str:
"""Search with optional params."""
return ""
schema = function_to_mcp_schema(search)
# Only required params should be in required list
assert schema["inputSchema"]["required"] == ["query"]
# All params should still be in properties
assert "max_results" in schema["inputSchema"]["properties"]
assert "include_images" in schema["inputSchema"]["properties"]
def test_function_with_list_param(self):
"""Test function with list parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def process(items: List[str]) -> str:
"""Process a list of items."""
return ""
schema = function_to_mcp_schema(process)
assert schema["inputSchema"]["properties"]["items"]["type"] == "array"
assert schema["inputSchema"]["properties"]["items"]["items"]["type"] == "string"
def test_function_with_dict_param(self):
"""Test function with dict parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def process(data: Dict[str, Any]) -> str:
"""Process data dictionary."""
return ""
schema = function_to_mcp_schema(process)
assert schema["inputSchema"]["properties"]["data"]["type"] == "object"
def test_function_with_optional_type(self):
"""Test function with Optional type hint."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, category: Optional[str] = None) -> str:
"""Search with optional category."""
return ""
schema = function_to_mcp_schema(search)
assert "category" in schema["inputSchema"]["properties"]
assert "category" not in schema["inputSchema"]["required"]
def test_function_without_docstring(self):
"""Test function without docstring uses function name as description."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def my_tool(x: str) -> str:
return x
schema = function_to_mcp_schema(my_tool)
assert schema["name"] == "my_tool"
assert schema["description"] == "my_tool" # Falls back to name
def test_async_function(self):
"""Test async function is handled correctly."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
async def async_search(query: str) -> str:
"""Async search function."""
return ""
schema = function_to_mcp_schema(async_search)
assert schema["name"] == "async_search"
assert schema["description"] == "Async search function."
class TestGetToolMetadata:
"""Test get_tool_metadata() extracts metadata from functions."""
def test_extracts_name(self):
"""Test name extraction."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Tool description."""
return x
metadata = get_tool_metadata(my_tool)
assert metadata["name"] == "my_tool"
def test_extracts_description_from_docstring(self):
"""Test description extraction from docstring."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""This is the tool description.
More details here.
"""
return x
metadata = get_tool_metadata(my_tool)
assert metadata["description"] == "This is the tool description."
def test_custom_name_attribute(self):
"""Test custom __mcp_name__ attribute is used."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Description."""
return x
my_tool.__mcp_name__ = "custom_name"
metadata = get_tool_metadata(my_tool)
assert metadata["name"] == "custom_name"
def test_custom_description_attribute(self):
"""Test custom __mcp_description__ attribute is used."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Original docstring."""
return x
my_tool.__mcp_description__ = "Custom description"
metadata = get_tool_metadata(my_tool)
assert metadata["description"] == "Custom description"
class TestPythonTypeToJsonSchema:
"""Test Python type to JSON Schema conversion."""
def test_basic_types(self):
"""Test basic Python types convert correctly."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
assert python_type_to_json_schema(str) == {"type": "string"}
assert python_type_to_json_schema(int) == {"type": "integer"}
assert python_type_to_json_schema(float) == {"type": "number"}
assert python_type_to_json_schema(bool) == {"type": "boolean"}
def test_list_type(self):
"""Test List type converts to array."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(List[str])
assert result["type"] == "array"
assert result["items"]["type"] == "string"
def test_dict_type(self):
"""Test Dict type converts to object."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Dict[str, Any])
assert result["type"] == "object"
def test_optional_type(self):
"""Test Optional type is handled."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Optional[str])
assert result["type"] == "string" # Optional just means nullable
def test_any_type(self):
"""Test Any type converts to empty schema."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Any)
assert result == {} # Any type = no constraints
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,569 @@
"""
Tests for advanced memory features:
- Session Save/Resume
- Context Compression
- Checkpointing
- Slash Commands
- Rules Manager
"""
import os
import sys
import pytest
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from praisonaiagents.memory import FileMemory, RulesManager, Rule
class TestSessionManagement:
"""Tests for session save/resume functionality."""
@pytest.fixture
def memory(self, tmp_path):
"""Create a FileMemory instance with temp directory."""
return FileMemory(user_id="test_user", base_path=str(tmp_path / "memory"))
def test_save_session(self, memory):
"""Test saving a session."""
# Add some memories
memory.add_short_term("User prefers Python")
memory.add_short_term("Working on AI project")
memory.add_long_term("User name is John")
# Save session
path = memory.save_session("test_session")
assert Path(path).exists()
assert "test_session.json" in path
def test_save_session_with_conversation(self, memory):
"""Test saving session with conversation history."""
conversation = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"}
]
path = memory.save_session("conv_session", conversation_history=conversation)
assert Path(path).exists()
def test_resume_session(self, memory):
"""Test resuming a saved session."""
# Add memories and save
memory.add_short_term("Important context")
memory.save_session("resume_test")
# Clear and verify empty
memory.clear_short_term()
assert len(memory.get_short_term()) == 0
# Resume session
memory.resume_session("resume_test")
# Verify restored
assert len(memory.get_short_term()) > 0
assert "Important context" in [m.content for m in memory.get_short_term()]
def test_list_sessions(self, memory):
"""Test listing saved sessions."""
memory.save_session("session1")
memory.save_session("session2")
sessions = memory.list_sessions()
assert len(sessions) >= 2
names = [s["name"] for s in sessions]
assert "session1" in names
assert "session2" in names
def test_delete_session(self, memory):
"""Test deleting a session."""
memory.save_session("to_delete")
assert memory.delete_session("to_delete")
sessions = memory.list_sessions()
names = [s["name"] for s in sessions]
assert "to_delete" not in names
def test_resume_nonexistent_session(self, memory):
"""Test resuming a session that doesn't exist."""
with pytest.raises(FileNotFoundError):
memory.resume_session("nonexistent")
class TestContextCompression:
"""Tests for context compression functionality."""
@pytest.fixture
def memory(self, tmp_path):
"""Create a FileMemory instance with temp directory."""
return FileMemory(
user_id="test_user",
base_path=str(tmp_path / "memory"),
config={"short_term_limit": 20}
)
def test_compress_basic(self, memory):
"""Test basic compression without LLM."""
# Add many items
for i in range(15):
memory.add_short_term(f"Context item {i}")
# Compress
summary = memory.compress(max_items=5)
# Verify compression
assert len(memory.get_short_term()) <= 5
assert len(summary) > 0
def test_compress_with_llm_func(self, memory):
"""Test compression with custom LLM function."""
for i in range(15):
memory.add_short_term(f"Item {i}")
# Mock LLM function
def mock_llm(prompt):
return "Summary: Multiple items discussed."
summary = memory.compress(llm_func=mock_llm, max_items=5)
assert "Summary:" in summary
def test_auto_compress_below_threshold(self, memory):
"""Test auto-compress doesn't trigger below threshold."""
# Add few items (below 70% of 20 = 14)
for i in range(10):
memory.add_short_term(f"Item {i}")
result = memory.auto_compress_if_needed(threshold_percent=0.7)
assert result is None
def test_auto_compress_above_threshold(self, memory):
"""Test auto-compress triggers above threshold."""
# Add many items (above 70% of 20 = 14)
for i in range(16):
memory.add_short_term(f"Item {i}")
result = memory.auto_compress_if_needed(threshold_percent=0.7)
assert result is not None or len(memory.get_short_term()) < 16
class TestCheckpointing:
"""Tests for checkpointing functionality."""
@pytest.fixture
def memory(self, tmp_path):
"""Create a FileMemory instance with temp directory."""
return FileMemory(user_id="test_user", base_path=str(tmp_path / "memory"))
def test_create_checkpoint(self, memory):
"""Test creating a checkpoint."""
memory.add_short_term("Before checkpoint")
memory.add_long_term("Important fact")
checkpoint_id = memory.create_checkpoint("test_checkpoint")
assert checkpoint_id == "test_checkpoint"
checkpoints = memory.list_checkpoints()
assert any(c["id"] == "test_checkpoint" for c in checkpoints)
def test_create_checkpoint_auto_name(self, memory):
"""Test creating checkpoint with auto-generated name."""
checkpoint_id = memory.create_checkpoint()
assert checkpoint_id.startswith("checkpoint_")
def test_restore_checkpoint(self, memory):
"""Test restoring from checkpoint."""
# Add initial data
memory.add_short_term("Original data")
memory.create_checkpoint("restore_test")
# Modify data
memory.clear_short_term()
memory.add_short_term("New data")
# Restore
success = memory.restore_checkpoint("restore_test")
assert success
contents = [m.content for m in memory.get_short_term()]
assert "Original data" in contents
def test_checkpoint_with_files(self, memory, tmp_path):
"""Test checkpoint with file snapshots."""
# Create a test file
test_file = tmp_path / "test_file.txt"
test_file.write_text("Original content")
# Create checkpoint with file
checkpoint_id = memory.create_checkpoint(
"file_checkpoint",
include_files=[str(test_file)]
)
# Modify file
test_file.write_text("Modified content")
# Restore with files
memory.restore_checkpoint(checkpoint_id, restore_files=True)
assert test_file.read_text() == "Original content"
def test_list_checkpoints(self, memory):
"""Test listing checkpoints."""
memory.create_checkpoint("cp1")
memory.create_checkpoint("cp2")
checkpoints = memory.list_checkpoints()
assert len(checkpoints) >= 2
ids = [c["id"] for c in checkpoints]
assert "cp1" in ids
assert "cp2" in ids
def test_delete_checkpoint(self, memory):
"""Test deleting a checkpoint."""
memory.create_checkpoint("to_delete")
assert memory.delete_checkpoint("to_delete")
checkpoints = memory.list_checkpoints()
ids = [c["id"] for c in checkpoints]
assert "to_delete" not in ids
class TestSlashCommands:
"""Tests for slash command handling."""
@pytest.fixture
def memory(self, tmp_path):
"""Create a FileMemory instance with temp directory."""
return FileMemory(user_id="test_user", base_path=str(tmp_path / "memory"))
def test_command_show(self, memory):
"""Test /memory show command."""
memory.add_short_term("Test item")
result = memory.handle_command("/memory show")
assert result["action"] == "show"
assert "stats" in result
assert "short_term" in result
def test_command_add(self, memory):
"""Test /memory add command."""
result = memory.handle_command("/memory add User likes coffee")
assert result["action"] == "add"
assert result["content"] == "User likes coffee"
# Verify it was added
long_term = memory.get_long_term()
assert any("coffee" in m.content for m in long_term)
def test_command_clear(self, memory):
"""Test /memory clear command."""
memory.add_short_term("To be cleared")
result = memory.handle_command("/memory clear short")
assert result["action"] == "clear"
assert len(memory.get_short_term()) == 0
def test_command_search(self, memory):
"""Test /memory search command."""
memory.add_long_term("Python programming")
result = memory.handle_command("/memory search Python")
assert result["action"] == "search"
assert "results" in result
def test_command_save(self, memory):
"""Test /memory save command."""
result = memory.handle_command("/memory save my_session")
assert result["action"] == "save"
assert result["session"] == "my_session"
def test_command_sessions(self, memory):
"""Test /memory sessions command."""
memory.save_session("test")
result = memory.handle_command("/memory sessions")
assert result["action"] == "sessions"
assert "sessions" in result
def test_command_checkpoint(self, memory):
"""Test /memory checkpoint command."""
result = memory.handle_command("/memory checkpoint my_cp")
assert result["action"] == "checkpoint"
assert result["id"] == "my_cp"
def test_command_checkpoints(self, memory):
"""Test /memory checkpoints command."""
memory.create_checkpoint("test")
result = memory.handle_command("/memory checkpoints")
assert result["action"] == "checkpoints"
assert "checkpoints" in result
def test_command_help(self, memory):
"""Test /memory help command."""
result = memory.handle_command("/memory help")
assert result["action"] == "help"
assert "commands" in result
assert "/memory show" in result["commands"]
def test_command_invalid(self, memory):
"""Test invalid command."""
result = memory.handle_command("/memory invalid_action")
assert "error" in result
class TestRulesManager:
"""Tests for RulesManager functionality."""
@pytest.fixture
def rules_dir(self, tmp_path):
"""Create a temp rules directory."""
rules_path = tmp_path / ".praison" / "rules"
rules_path.mkdir(parents=True)
return rules_path
@pytest.fixture
def manager(self, tmp_path, rules_dir):
"""Create a RulesManager instance."""
return RulesManager(workspace_path=str(tmp_path))
def test_create_rule(self, manager, tmp_path):
"""Test creating a rule."""
rule = manager.create_rule(
name="python_style",
content="# Python Style\n- Use type hints\n- Follow PEP 8",
description="Python coding guidelines",
globs=["**/*.py"],
activation="glob"
)
assert rule.name == "python_style"
assert rule.activation == "glob"
assert "**/*.py" in rule.globs
# Verify file was created
rule_file = tmp_path / ".praison" / "rules" / "python_style.md"
assert rule_file.exists()
def test_load_rule_with_frontmatter(self, rules_dir, tmp_path):
"""Test loading a rule with YAML frontmatter."""
rule_content = """---
description: Test rule
globs: ["*.py", "*.pyx"]
activation: glob
priority: 10
---
# Test Rule
- Rule content here
"""
(rules_dir / "test_rule.md").write_text(rule_content)
manager = RulesManager(workspace_path=str(tmp_path))
rule = manager.get_rule_by_name("test_rule")
assert rule is not None
assert rule.description == "Test rule"
assert "*.py" in rule.globs
assert rule.activation == "glob"
assert rule.priority == 10
def test_get_active_rules_always(self, manager):
"""Test getting always-active rules."""
manager.create_rule(
name="always_rule",
content="Always active",
activation="always"
)
active = manager.get_active_rules()
assert any(r.name == "always_rule" for r in active)
def test_get_rules_for_file_glob(self, manager):
"""Test getting rules for a specific file."""
manager.create_rule(
name="py_rule",
content="Python rule",
globs=["**/*.py"],
activation="glob"
)
manager.create_rule(
name="js_rule",
content="JS rule",
globs=["**/*.js"],
activation="glob"
)
py_rules = manager.get_rules_for_file("src/main.py")
rule_names = [r.name for r in py_rules]
assert "py_rule" in rule_names
# js_rule should not match .py files
def test_get_manual_rules(self, manager):
"""Test getting manual-only rules."""
manager.create_rule(
name="manual_rule",
content="Manual only",
activation="manual"
)
manual = manager.get_manual_rules()
assert any(r.name == "manual_rule" for r in manual)
def test_build_rules_context(self, manager):
"""Test building context string from rules."""
manager.create_rule(
name="rule1",
content="Rule 1 content",
description="First rule",
activation="always"
)
manager.create_rule(
name="rule2",
content="Rule 2 content",
description="Second rule",
activation="always"
)
context = manager.build_rules_context()
assert "rule1" in context
assert "Rule 1 content" in context
def test_build_rules_context_with_manual(self, manager):
"""Test building context with manual rules included."""
manager.create_rule(
name="auto_rule",
content="Auto content",
activation="always"
)
manager.create_rule(
name="manual_rule",
content="Manual content",
activation="manual"
)
# Without manual
context1 = manager.build_rules_context()
assert "manual_rule" not in context1
# With manual included
context2 = manager.build_rules_context(include_manual=["manual_rule"])
assert "Manual content" in context2
def test_delete_rule(self, manager):
"""Test deleting a rule."""
manager.create_rule(name="to_delete", content="Delete me")
assert manager.delete_rule("to_delete")
assert manager.get_rule_by_name("to_delete") is None
def test_get_stats(self, manager):
"""Test getting rules statistics."""
manager.create_rule(name="r1", content="c1", activation="always")
manager.create_rule(name="r2", content="c2", activation="glob", globs=["*.py"])
manager.create_rule(name="r3", content="c3", activation="manual")
stats = manager.get_stats()
assert stats["total_rules"] >= 3
assert stats["always_rules"] >= 1
assert stats["glob_rules"] >= 1
assert stats["manual_rules"] >= 1
def test_reload_rules(self, manager, rules_dir):
"""Test reloading rules from disk."""
# Create rule via manager
manager.create_rule(name="initial", content="Initial")
# Create rule directly on disk
new_rule = """---
description: New rule
activation: always
---
New rule content
"""
(rules_dir / "new_rule.md").write_text(new_rule)
# Reload
manager.reload()
# Should find new rule
assert manager.get_rule_by_name("new_rule") is not None
class TestRuleMatching:
"""Tests for rule glob pattern matching."""
def test_rule_matches_exact_glob(self):
"""Test exact glob matching."""
rule = Rule(
name="test",
content="test",
globs=["*.py"],
activation="glob"
)
assert rule.matches_file("main.py")
assert not rule.matches_file("main.js")
def test_rule_matches_recursive_glob(self):
"""Test recursive glob matching."""
rule = Rule(
name="test",
content="test",
globs=["**/*.py"],
activation="glob"
)
assert rule.matches_file("src/main.py")
assert rule.matches_file("src/utils/helper.py")
def test_rule_always_matches(self):
"""Test always activation mode."""
rule = Rule(
name="test",
content="test",
activation="always"
)
assert rule.matches_file("anything.txt")
assert rule.matches_file("src/main.py")
def test_rule_manual_never_matches(self):
"""Test manual activation mode never auto-matches."""
rule = Rule(
name="test",
content="test",
activation="manual"
)
assert not rule.matches_file("anything.txt")
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,570 @@
"""
Comprehensive tests for new memory features.
Tests:
- CLAUDE.local.md and local override files
- .claude/rules/, .windsurf/rules/, .cursor/rules/ discovery
- Git root discovery
- @Import syntax
- Rules character limit (12000)
- ai_decision activation mode
- Auto-generated memories
- Workflows
- Hooks
"""
import sys
import json
import tempfile
import unittest
from pathlib import Path
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from praisonaiagents.memory import (
FileMemory,
RulesManager,
)
class TestLocalOverrideFiles(unittest.TestCase):
"""Test CLAUDE.local.md and PRAISON.local.md support."""
def test_claude_local_md_loaded(self):
"""Test that CLAUDE.local.md is discovered."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create CLAUDE.md
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text("# Main Claude Rules\n- Rule 1")
# Create CLAUDE.local.md (higher priority)
claude_local = Path(tmpdir) / "CLAUDE.local.md"
claude_local.write_text("# Local Overrides\n- Override 1")
manager = RulesManager(workspace_path=tmpdir)
self.assertIn("root:claude", manager._rules)
self.assertIn("root:claude_local", manager._rules)
# Local should have higher priority
claude_rule = manager._rules["root:claude"]
local_rule = manager._rules["root:claude_local"]
self.assertGreater(local_rule.priority, claude_rule.priority)
def test_praison_local_md_loaded(self):
"""Test that PRAISON.local.md is discovered."""
with tempfile.TemporaryDirectory() as tmpdir:
praison_local = Path(tmpdir) / "PRAISON.local.md"
praison_local.write_text("# Local Praison Rules")
manager = RulesManager(workspace_path=tmpdir)
self.assertIn("root:praison_local", manager._rules)
class TestAdditionalRulesDirectories(unittest.TestCase):
"""Test .claude/rules/, .windsurf/rules/, .cursor/rules/ discovery."""
def test_claude_rules_directory(self):
"""Test .claude/rules/ discovery."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".claude" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "python.md").write_text("# Python Rules\n- Use type hints")
(rules_dir / "testing.md").write_text("# Testing Rules\n- Use pytest")
manager = RulesManager(workspace_path=tmpdir)
stats = manager.get_stats()
self.assertGreaterEqual(stats["total_rules"], 2)
self.assertIn("claude:python", manager._rules)
self.assertIn("claude:testing", manager._rules)
def test_windsurf_rules_directory(self):
"""Test .windsurf/rules/ discovery."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".windsurf" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "style.md").write_text("# Style Rules")
manager = RulesManager(workspace_path=tmpdir)
self.assertIn("windsurf:style", manager._rules)
def test_cursor_rules_directory(self):
"""Test .cursor/rules/ discovery."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".cursor" / "rules"
rules_dir.mkdir(parents=True)
(rules_dir / "code.mdc").write_text("# Code Rules")
manager = RulesManager(workspace_path=tmpdir)
self.assertIn("cursor:code", manager._rules)
class TestGitRootDiscovery(unittest.TestCase):
"""Test git root discovery for monorepo support."""
def test_find_git_root(self):
"""Test _find_git_root method."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create fake git repo
git_dir = Path(tmpdir) / ".git"
git_dir.mkdir()
# Create subdirectory
subdir = Path(tmpdir) / "packages" / "frontend"
subdir.mkdir(parents=True)
manager = RulesManager(workspace_path=str(subdir))
git_root = manager._find_git_root()
# Resolve both paths to handle /private/var vs /var on macOS
self.assertEqual(git_root.resolve(), Path(tmpdir).resolve())
def test_rules_from_git_root(self):
"""Test that rules are loaded from git root."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create fake git repo
git_dir = Path(tmpdir) / ".git"
git_dir.mkdir()
# Create CLAUDE.md at git root
(Path(tmpdir) / "CLAUDE.md").write_text("# Root Rules")
# Create subdirectory
subdir = Path(tmpdir) / "packages" / "frontend"
subdir.mkdir(parents=True)
# Create CLAUDE.md in subdirectory
(subdir / "CLAUDE.md").write_text("# Subdir Rules")
manager = RulesManager(workspace_path=str(subdir))
# Should have both root and gitroot rules
self.assertIn("root:claude", manager._rules)
class TestImportSyntax(unittest.TestCase):
"""Test @import syntax for including other files."""
def test_basic_import(self):
"""Test basic @path/to/file import."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create file to import
docs_dir = Path(tmpdir) / "docs"
docs_dir.mkdir()
(docs_dir / "guidelines.md").write_text("# Guidelines\n- Be concise")
# Create CLAUDE.md with import
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text("# Main Rules\n@docs/guidelines.md\n- More rules")
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
self.assertIsNotNone(rule)
self.assertIn("Be concise", rule.content)
def test_relative_import(self):
"""Test @./relative/path import."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create file to import
(Path(tmpdir) / "shared.md").write_text("# Shared Content")
# Create CLAUDE.md with relative import
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text("Include: @./shared.md")
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
self.assertIn("Shared Content", rule.content)
def test_import_depth_limit(self):
"""Test that import depth is limited to prevent loops."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create circular imports
(Path(tmpdir) / "a.md").write_text("A imports @./b.md")
(Path(tmpdir) / "b.md").write_text("B imports @./c.md")
(Path(tmpdir) / "c.md").write_text("C imports @./a.md") # Circular
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text("Start: @./a.md")
# Should not hang or crash
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
self.assertIsNotNone(rule)
def test_code_span_not_imported(self):
"""Test that @mentions in code spans are not imported."""
with tempfile.TemporaryDirectory() as tmpdir:
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text("Use `@org/package` for imports")
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
# Should not try to import @org/package
self.assertIn("@org/package", rule.content)
class TestRulesCharacterLimit(unittest.TestCase):
"""Test 12000 character limit for rules."""
def test_truncation(self):
"""Test that long rules are truncated."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create very long rule
long_content = "x" * 15000
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text(long_content)
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
self.assertLessEqual(len(rule.content), 12000)
self.assertIn("truncated", rule.content)
def test_short_rules_not_truncated(self):
"""Test that short rules are not truncated."""
with tempfile.TemporaryDirectory() as tmpdir:
short_content = "# Short Rule\n- Item 1"
claude_md = Path(tmpdir) / "CLAUDE.md"
claude_md.write_text(short_content)
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules.get("root:claude")
self.assertNotIn("truncated", rule.content)
class TestAiDecisionActivation(unittest.TestCase):
"""Test ai_decision activation mode."""
def test_ai_decision_rule_loaded(self):
"""Test that ai_decision rules are loaded."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".praison" / "rules"
rules_dir.mkdir(parents=True)
rule_content = """---
description: Security guidelines
activation: ai_decision
---
# Security Rules
- Validate inputs
"""
(rules_dir / "security.md").write_text(rule_content)
manager = RulesManager(workspace_path=tmpdir)
self.assertIn("workspace:security", manager._rules)
rule = manager._rules["workspace:security"]
self.assertEqual(rule.activation, "ai_decision")
def test_evaluate_ai_decision_without_llm(self):
"""Test ai_decision evaluation without LLM (defaults to True)."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".praison" / "rules"
rules_dir.mkdir(parents=True)
rule_content = """---
activation: ai_decision
description: Security rules
---
# Security
"""
(rules_dir / "security.md").write_text(rule_content)
manager = RulesManager(workspace_path=tmpdir)
rule = manager._rules["workspace:security"]
# Without LLM, should return True
result = manager.evaluate_ai_decision(rule, "some context")
self.assertTrue(result)
class TestAutoMemory(unittest.TestCase):
"""Test auto-generated memories."""
def test_extract_name(self):
"""Test name extraction from text."""
from praisonaiagents.memory import AutoMemoryExtractor
extractor = AutoMemoryExtractor()
memories = extractor.extract("My name is John and I work at Acme.")
names = [m for m in memories if m["type"] == "name"]
self.assertGreater(len(names), 0)
self.assertEqual(names[0]["content"], "John")
def test_extract_preference(self):
"""Test preference extraction."""
from praisonaiagents.memory import AutoMemoryExtractor
extractor = AutoMemoryExtractor()
memories = extractor.extract("I prefer Python for backend development.")
prefs = [m for m in memories if m["type"] == "preference"]
self.assertGreater(len(prefs), 0)
def test_extract_technology(self):
"""Test technology extraction."""
from praisonaiagents.memory import AutoMemoryExtractor
extractor = AutoMemoryExtractor()
memories = extractor.extract("I'm using Python and TypeScript.")
techs = [m for m in memories if m["type"] == "technology"]
self.assertGreater(len(techs), 0)
def test_should_remember(self):
"""Test quick filter for memorable content."""
from praisonaiagents.memory import AutoMemoryExtractor
extractor = AutoMemoryExtractor()
self.assertTrue(extractor.should_remember("My name is John"))
self.assertTrue(extractor.should_remember("I prefer dark mode"))
self.assertFalse(extractor.should_remember("Hello world"))
def test_auto_memory_wrapper(self):
"""Test AutoMemory wrapper with FileMemory."""
from praisonaiagents.memory import AutoMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(user_id="test", base_path=f"{tmpdir}/memory")
auto = AutoMemory(memory, enabled=True)
# Process interaction
memories = auto.process_interaction(
"My name is Alice and I prefer Python",
store=True
)
self.assertGreater(len(memories), 0)
# Check stored in base memory
stats = memory.get_stats()
self.assertGreater(stats["long_term_count"] + stats["entity_count"], 0)
class TestWorkflows(unittest.TestCase):
"""Test workflow system."""
def test_workflow_discovery(self):
"""Test workflow discovery from .praison/workflows/."""
from praisonaiagents.memory import WorkflowManager
with tempfile.TemporaryDirectory() as tmpdir:
workflows_dir = Path(tmpdir) / ".praison" / "workflows"
workflows_dir.mkdir(parents=True)
workflow_content = """---
name: Deploy
description: Deploy to production
---
## Step 1: Test
Run tests.
```action
Run pytest
```
## Step 2: Build
Build the app.
```action
Build application
```
"""
(workflows_dir / "deploy.md").write_text(workflow_content)
manager = WorkflowManager(workspace_path=tmpdir)
workflows = manager.list_workflows()
self.assertEqual(len(workflows), 1)
self.assertEqual(workflows[0].name, "Deploy")
self.assertEqual(len(workflows[0].steps), 2)
def test_workflow_execution(self):
"""Test workflow execution."""
from praisonaiagents.memory import WorkflowManager
with tempfile.TemporaryDirectory() as tmpdir:
workflows_dir = Path(tmpdir) / ".praison" / "workflows"
workflows_dir.mkdir(parents=True)
workflow_content = """---
name: Test
---
## Step 1: Echo
Echo environment.
```action
Echo staging
```
"""
(workflows_dir / "test.md").write_text(workflow_content)
manager = WorkflowManager(workspace_path=tmpdir)
# Mock executor
executed = []
def executor(prompt):
executed.append(prompt)
return f"Executed: {prompt}"
result = manager.execute("test", executor=executor)
self.assertTrue(result["success"])
self.assertEqual(len(executed), 1)
self.assertIn("staging", executed[0])
def test_create_workflow(self):
"""Test programmatic workflow creation."""
from praisonaiagents.memory import WorkflowManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = WorkflowManager(workspace_path=tmpdir)
workflow = manager.create_workflow(
name="My Workflow",
description="Test workflow",
steps=[
{"name": "Step 1", "action": "Do something"},
{"name": "Step 2", "action": "Do something else"}
]
)
self.assertEqual(workflow.name, "My Workflow")
self.assertEqual(len(workflow.steps), 2)
# Check file was created
self.assertTrue(Path(workflow.file_path).exists())
class TestHooks(unittest.TestCase):
"""Test hooks system."""
def test_hooks_config_loading(self):
"""Test hooks configuration loading."""
from praisonaiagents.memory import HooksManager
with tempfile.TemporaryDirectory() as tmpdir:
config_dir = Path(tmpdir) / ".praison"
config_dir.mkdir()
config = {
"enabled": True,
"timeout": 30,
"hooks": {
"pre_write_code": "echo 'pre-write'",
"post_write_code": "echo 'post-write'"
}
}
(config_dir / "hooks.json").write_text(json.dumps(config))
manager = HooksManager(workspace_path=tmpdir)
stats = manager.get_stats()
self.assertTrue(stats["enabled"])
self.assertEqual(stats["script_hooks"], 2)
def test_callable_hook_registration(self):
"""Test Python callable hook registration."""
from praisonaiagents.memory import HooksManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = HooksManager(workspace_path=tmpdir)
called = []
def my_hook(context):
called.append(context)
return {"modified": True}
manager.register("pre_write_code", my_hook)
result = manager.execute("pre_write_code", {"file": "test.py"})
self.assertTrue(result.success)
self.assertEqual(len(called), 1)
self.assertEqual(called[0]["file"], "test.py")
def test_has_hooks(self):
"""Test has_hooks method."""
from praisonaiagents.memory import HooksManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = HooksManager(workspace_path=tmpdir)
self.assertFalse(manager.has_hooks("pre_write_code"))
manager.register("pre_write_code", lambda ctx: None)
self.assertTrue(manager.has_hooks("pre_write_code"))
def test_create_config(self):
"""Test hooks config creation."""
from praisonaiagents.memory import HooksManager
with tempfile.TemporaryDirectory() as tmpdir:
manager = HooksManager(workspace_path=tmpdir)
manager.create_config(
hooks={"pre_write_code": "echo 'test'"},
timeout=60
)
config_path = Path(tmpdir) / ".praison" / "hooks.json"
self.assertTrue(config_path.exists())
config = json.loads(config_path.read_text())
self.assertEqual(config["timeout"], 60)
class TestSymlinkSupport(unittest.TestCase):
"""Test symlink support for shared rules."""
def test_symlink_rule_file(self):
"""Test that symlinked rule files are loaded."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create actual rule file
shared_dir = Path(tmpdir) / "shared"
shared_dir.mkdir()
(shared_dir / "common.md").write_text("# Common Rules")
# Create symlink
project_dir = Path(tmpdir) / "project"
project_dir.mkdir()
try:
(project_dir / "CLAUDE.md").symlink_to(shared_dir / "common.md")
except OSError:
# Skip on systems that don't support symlinks
self.skipTest("Symlinks not supported")
manager = RulesManager(workspace_path=str(project_dir))
# After symlink resolution, the name comes from the resolved file
# Check that some rule with Common Rules content exists
found = False
for rule in manager._rules.values():
if "Common Rules" in rule.content:
found = True
break
self.assertTrue(found, "Symlinked rule content not found")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,433 @@
"""
Integration tests for PraisonAI Memory System.
Tests the complete memory system with real API calls:
- FileMemory with session save/resume
- Context compression with LLM
- Rules management with CLAUDE.md, AGENTS.md, GEMINI.md
- Multi-agent workflows with shared memory
Requires: OPENAI_API_KEY environment variable
"""
import os
import sys
import tempfile
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from praisonaiagents import Agent
from praisonaiagents.memory import FileMemory, RulesManager
def test_file_memory_with_agent():
"""Test FileMemory integration with Agent."""
print("\n" + "="*60)
print("TEST: FileMemory with Agent")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
# Create memory instance
memory = FileMemory(user_id="test_user", base_path=f"{tmpdir}/memory")
# Add some context
memory.add_long_term("User prefers Python for backend development", importance=0.9)
memory.add_long_term("User's name is John", importance=0.95)
memory.add_short_term("Currently working on an AI project")
# Create agent with memory
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Use the memory context to personalize responses.",
memory=memory,
verbose=True
)
# Test that memory context is included
response = agent.chat("What do you know about me?")
print(f"\nAgent Response: {response}")
# Verify memory was used
assert memory.get_stats()["long_term_count"] >= 2
print("✓ FileMemory integration test passed")
def test_session_save_resume():
"""Test session save and resume functionality."""
print("\n" + "="*60)
print("TEST: Session Save/Resume")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
# Create memory and add context
memory = FileMemory(user_id="session_test", base_path=f"{tmpdir}/memory")
memory.add_short_term("Important context from previous session")
memory.add_short_term("User asked about machine learning")
memory.add_long_term("User is a data scientist", importance=0.9)
# Save session with conversation history
conversation = [
{"role": "user", "content": "Tell me about ML"},
{"role": "assistant", "content": "Machine learning is..."}
]
session_path = memory.save_session("ml_session", conversation_history=conversation)
print(f"Session saved to: {session_path}")
# Clear memory
memory.clear_short_term()
assert len(memory.get_short_term()) == 0
# Resume session
session_data = memory.resume_session("ml_session")
print(f"Session resumed with {len(session_data.get('conversation_history', []))} messages")
# Verify restoration
assert len(memory.get_short_term()) > 0
assert len(session_data.get("conversation_history", [])) == 2
print("✓ Session save/resume test passed")
def test_context_compression_with_llm():
"""Test context compression with real LLM."""
print("\n" + "="*60)
print("TEST: Context Compression with LLM")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(
user_id="compress_test",
base_path=f"{tmpdir}/memory",
config={"short_term_limit": 20}
)
# Add many items to trigger compression
for i in range(15):
memory.add_short_term(f"User discussed topic {i}: some detailed context about item {i}")
print(f"Short-term items before compression: {len(memory.get_short_term())}")
# Create LLM function for compression using Agent
def llm_summarize(prompt):
from praisonaiagents import Agent
summarizer = Agent(
name="Summarizer",
instructions="You summarize text concisely.",
verbose=False
)
return summarizer.chat(prompt)
# Compress with LLM
summary = memory.compress(llm_func=llm_summarize, max_items=5)
print(f"Compression summary: {summary[:100]}...")
print(f"Short-term items after compression: {len(memory.get_short_term())}")
# Verify compression
assert len(memory.get_short_term()) <= 5
assert len(summary) > 0
print("✓ Context compression test passed")
def test_checkpointing():
"""Test checkpointing with file snapshots."""
print("\n" + "="*60)
print("TEST: Checkpointing")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(user_id="checkpoint_test", base_path=f"{tmpdir}/memory")
# Create a test file
test_file = Path(tmpdir) / "code.py"
test_file.write_text("def hello():\n print('Hello')")
# Add memory and create checkpoint
memory.add_long_term("Original implementation uses hello function")
checkpoint_id = memory.create_checkpoint("before_refactor", include_files=[str(test_file)])
print(f"Created checkpoint: {checkpoint_id}")
# Modify file and memory
test_file.write_text("def greet(name):\n print(f'Hello {name}')")
memory.clear_all()
memory.add_long_term("Refactored to use greet function")
print(f"File after modification: {test_file.read_text()[:30]}...")
# Restore checkpoint
memory.restore_checkpoint(checkpoint_id, restore_files=True)
print(f"File after restore: {test_file.read_text()[:30]}...")
# Verify restoration
assert "hello" in test_file.read_text()
assert memory.get_stats()["long_term_count"] > 0
print("✓ Checkpointing test passed")
def test_rules_manager_root_files():
"""Test RulesManager with CLAUDE.md, AGENTS.md, GEMINI.md files."""
print("\n" + "="*60)
print("TEST: RulesManager with Root Instruction Files")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
# Create root instruction files
claude_content = """# Claude Instructions
- Always be helpful and concise
- Use Python for code examples
- Follow best practices
"""
agents_content = """# Codex Instructions
- Run tests before committing
- Document all functions
"""
gemini_content = """# Gemini Instructions
- Prefer functional programming
- Use type hints
"""
praison_content = """---
description: PraisonAI project rules
activation: always
priority: 100
---
# PraisonAI Rules
- Use async when possible
- Follow PEP 8
"""
(Path(tmpdir) / "CLAUDE.md").write_text(claude_content)
(Path(tmpdir) / "AGENTS.md").write_text(agents_content)
(Path(tmpdir) / "GEMINI.md").write_text(gemini_content)
(Path(tmpdir) / "PRAISON.md").write_text(praison_content)
# Create RulesManager
rules_manager = RulesManager(workspace_path=tmpdir, verbose=1)
# Check stats
stats = rules_manager.get_stats()
print(f"Rules stats: {stats}")
# Verify root files were loaded
assert stats["root_rules"] >= 4, f"Expected at least 4 root rules, got {stats['root_rules']}"
# Get rules by name
claude_rule = rules_manager.get_rule_by_name("claude")
agents_rule = rules_manager.get_rule_by_name("agents")
gemini_rule = rules_manager.get_rule_by_name("gemini")
praison_rule = rules_manager.get_rule_by_name("praison")
assert claude_rule is not None, "CLAUDE.md not loaded"
assert agents_rule is not None, "AGENTS.md not loaded"
assert gemini_rule is not None, "GEMINI.md not loaded"
assert praison_rule is not None, "PRAISON.md not loaded"
print(f"Claude rule: {claude_rule.description}")
print(f"Agents rule: {agents_rule.description}")
print(f"Gemini rule: {gemini_rule.description}")
print(f"Praison rule: {praison_rule.description}")
# Build context
context = rules_manager.build_rules_context()
print(f"\nRules context ({len(context)} chars):")
print(context[:500] + "..." if len(context) > 500 else context)
assert "helpful" in context.lower() or "python" in context.lower()
print("✓ RulesManager root files test passed")
def test_multi_agent_with_shared_memory():
"""Test multiple agents sharing memory."""
print("\n" + "="*60)
print("TEST: Multi-Agent with Shared Memory")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
# Create shared memory
shared_memory = FileMemory(user_id="shared", base_path=f"{tmpdir}/memory")
shared_memory.add_long_term("Project uses Python 3.12", importance=0.9)
shared_memory.add_long_term("Database is PostgreSQL", importance=0.85)
# Create multiple agents with shared memory
researcher = Agent(
name="Researcher",
role="Research Analyst",
goal="Research and gather information",
backstory="Expert at finding and analyzing information",
memory=shared_memory,
verbose=True
)
writer = Agent(
name="Writer",
role="Technical Writer",
goal="Write clear documentation",
backstory="Expert at technical writing",
memory=shared_memory,
verbose=True
)
# Test researcher
print("\n--- Researcher Agent ---")
research_response = researcher.chat("What technology stack are we using?")
print(f"Researcher: {research_response[:200]}...")
# Add finding to memory
shared_memory.add_short_term("Researcher confirmed: Python 3.12 + PostgreSQL stack")
# Test writer (should see researcher's finding)
print("\n--- Writer Agent ---")
writer_response = writer.chat("What should I document about our stack?")
print(f"Writer: {writer_response[:200]}...")
# Verify shared memory
stats = shared_memory.get_stats()
print(f"\nShared memory stats: {stats}")
assert stats["short_term_count"] >= 1
assert stats["long_term_count"] >= 2
print("✓ Multi-agent shared memory test passed")
def test_slash_commands():
"""Test memory slash commands."""
print("\n" + "="*60)
print("TEST: Memory Slash Commands")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(user_id="commands_test", base_path=f"{tmpdir}/memory")
# Test /memory add
result = memory.handle_command("/memory add User prefers dark mode")
print(f"/memory add: {result}")
assert result["action"] == "add"
# Test /memory show
result = memory.handle_command("/memory show")
print(f"/memory show: {result['stats']}")
assert result["action"] == "show"
assert result["stats"]["long_term_count"] >= 1
# Test /memory search
result = memory.handle_command("/memory search dark mode")
print(f"/memory search: {len(result.get('results', []))} results")
assert result["action"] == "search"
# Test /memory save
result = memory.handle_command("/memory save test_session")
print(f"/memory save: {result}")
assert result["action"] == "save"
# Test /memory sessions
result = memory.handle_command("/memory sessions")
print(f"/memory sessions: {len(result.get('sessions', []))} sessions")
assert result["action"] == "sessions"
# Test /memory checkpoint
result = memory.handle_command("/memory checkpoint test_cp")
print(f"/memory checkpoint: {result}")
assert result["action"] == "checkpoint"
# Test /memory checkpoints
result = memory.handle_command("/memory checkpoints")
print(f"/memory checkpoints: {len(result.get('checkpoints', []))} checkpoints")
assert result["action"] == "checkpoints"
# Test /memory help
result = memory.handle_command("/memory help")
print(f"/memory help: {len(result.get('commands', {}))} commands")
assert result["action"] == "help"
assert len(result["commands"]) >= 10
print("✓ Slash commands test passed")
def test_agent_with_rules():
"""Test Agent with RulesManager integration."""
print("\n" + "="*60)
print("TEST: Agent with Rules Integration")
print("="*60)
with tempfile.TemporaryDirectory() as tmpdir:
# Create PRAISON.md in workspace
praison_rules = """# Project Rules
- Always respond in a friendly manner
- Use bullet points for lists
- Keep responses concise
"""
(Path(tmpdir) / "PRAISON.md").write_text(praison_rules)
# Change to temp directory to test rules discovery
original_cwd = os.getcwd()
os.chdir(tmpdir)
try:
# Create agent (should auto-discover rules)
agent = Agent(
name="RulesAgent",
instructions="You are a helpful assistant.",
verbose=True
)
# Check if rules were loaded
if agent._rules_manager:
stats = agent._rules_manager.get_stats()
print(f"Rules discovered: {stats}")
# Get rules context
context = agent.get_rules_context()
print(f"Rules context: {context[:200]}..." if context else "No rules context")
# Test agent response
response = agent.chat("List 3 benefits of Python")
print(f"\nAgent response: {response[:300]}...")
print("✓ Agent with rules test passed")
finally:
os.chdir(original_cwd)
def run_all_tests():
"""Run all integration tests."""
print("\n" + "="*60)
print("PRAISONAI MEMORY SYSTEM - INTEGRATION TESTS")
print("="*60)
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("WARNING: OPENAI_API_KEY not set. Some tests may fail.")
tests = [
("FileMemory with Agent", test_file_memory_with_agent),
("Session Save/Resume", test_session_save_resume),
("Context Compression", test_context_compression_with_llm),
("Checkpointing", test_checkpointing),
("RulesManager Root Files", test_rules_manager_root_files),
("Multi-Agent Shared Memory", test_multi_agent_with_shared_memory),
("Slash Commands", test_slash_commands),
("Agent with Rules", test_agent_with_rules),
]
passed = 0
failed = 0
for name, test_func in tests:
try:
test_func()
passed += 1
except Exception as e:
print(f"\n✗ {name} FAILED: {e}")
import traceback
traceback.print_exc()
failed += 1
print("\n" + "="*60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("="*60)
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,407 @@
"""
Test-Driven Development tests for Planning Mode with Tools Support.
This module contains tests for:
- PlanningAgent with tools parameter
- PlanningAgent with reasoning mode
- PraisonAIAgents with planning_tools parameter
- Research-enabled planning
"""
import pytest
import json
from unittest.mock import Mock, MagicMock, patch, AsyncMock
# =============================================================================
# SECTION 1: PlanningAgent Tools Parameter Tests
# =============================================================================
class TestPlanningAgentToolsParameter:
"""Tests for PlanningAgent tools parameter."""
def test_planning_agent_accepts_tools_parameter(self):
"""Test that PlanningAgent accepts tools parameter."""
from praisonaiagents.planning import PlanningAgent
def search_tool(query: str) -> str:
"""Search the web."""
return f"Results for {query}"
planner = PlanningAgent(tools=[search_tool])
assert planner.tools is not None
assert len(planner.tools) == 1
def test_planning_agent_tools_default_empty(self):
"""Test that tools defaults to empty list."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent()
assert planner.tools == []
def test_planning_agent_with_multiple_tools(self):
"""Test PlanningAgent with multiple tools."""
from praisonaiagents.planning import PlanningAgent
def tool1(x: str) -> str:
return x
def tool2(x: str) -> str:
return x
def tool3(x: str) -> str:
return x
planner = PlanningAgent(tools=[tool1, tool2, tool3])
assert len(planner.tools) == 3
# =============================================================================
# SECTION 2: PlanningAgent Internal Agent Tests
# =============================================================================
class TestPlanningAgentInternalAgent:
"""Tests for PlanningAgent internal Agent creation."""
def test_planning_agent_creates_internal_agent_when_tools_provided(self):
"""Test that internal Agent is created when tools are provided."""
from praisonaiagents.planning import PlanningAgent
def search_tool(query: str) -> str:
"""Search the web."""
return f"Results for {query}"
planner = PlanningAgent(tools=[search_tool])
# Internal agent should be created lazily
agent = planner._get_agent()
assert agent is not None
assert agent.tools is not None
def test_planning_agent_no_internal_agent_without_tools(self):
"""Test that no internal Agent is created without tools."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent()
agent = planner._get_agent()
assert agent is None
def test_planning_agent_internal_agent_uses_planning_llm(self):
"""Test that internal Agent uses the planning LLM."""
from praisonaiagents.planning import PlanningAgent
def search_tool(query: str) -> str:
return "results"
planner = PlanningAgent(llm="gpt-4o", tools=[search_tool])
agent = planner._get_agent()
assert agent is not None
# The agent should use the same LLM model
assert agent.llm == "gpt-4o"
# =============================================================================
# SECTION 3: PlanningAgent Create Plan with Tools Tests
# =============================================================================
class TestPlanningAgentCreatePlanWithTools:
"""Tests for create_plan_sync with tools."""
def test_create_plan_sync_uses_agent_when_tools_provided(self):
"""Test that create_plan_sync uses Agent.chat when tools provided."""
from praisonaiagents.planning import PlanningAgent
from praisonaiagents import Agent
def search_tool(query: str) -> str:
"""Search the web for information."""
return f"Search results for: {query}"
planner = PlanningAgent(tools=[search_tool])
# Mock the internal agent's chat method
mock_agent = MagicMock()
mock_agent.chat = MagicMock(return_value=json.dumps({
"name": "Research Plan",
"description": "Plan created with research",
"steps": [
{"description": "Research topic", "agent": "Researcher"},
{"description": "Write content", "agent": "Writer"}
]
}))
mock_agent.tools = [search_tool]
mock_agent.llm = "gpt-4o-mini"
planner._agent = mock_agent
agents = [Agent(name="Researcher", role="Researcher")]
plan = planner.create_plan_sync(
request="Research AI trends",
agents=agents
)
# Verify agent.chat was called (not LLM.get_response)
mock_agent.chat.assert_called_once()
assert plan.name == "Research Plan"
def test_create_plan_sync_uses_llm_when_no_tools(self):
"""Test that create_plan_sync uses LLM when no tools provided."""
from praisonaiagents.planning import PlanningAgent
from praisonaiagents import Agent
planner = PlanningAgent()
# Mock the LLM
mock_llm = MagicMock()
mock_llm.get_response = MagicMock(return_value=json.dumps({
"name": "Simple Plan",
"description": "Plan without research",
"steps": [{"description": "Step 1"}]
}))
planner._llm = mock_llm
agents = [Agent(name="Worker", role="Worker")]
plan = planner.create_plan_sync(
request="Simple task",
agents=agents
)
# Verify LLM.get_response was called
mock_llm.get_response.assert_called_once()
assert plan.name == "Simple Plan"
# =============================================================================
# SECTION 4: PraisonAIAgents planning_tools Parameter Tests
# =============================================================================
class TestPraisonAIAgentsPlanningTools:
"""Tests for PraisonAIAgents planning_tools parameter."""
def test_praisonaiagents_accepts_planning_tools(self):
"""Test that PraisonAIAgents accepts planning_tools parameter."""
from praisonaiagents import Agent, Task, PraisonAIAgents
def search_tool(query: str) -> str:
return "results"
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_tools=[search_tool]
)
assert agents.planning_tools is not None
assert len(agents.planning_tools) == 1
def test_praisonaiagents_planning_tools_default_none(self):
"""Test that planning_tools defaults to None."""
from praisonaiagents import Agent, Task, PraisonAIAgents
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True
)
assert agents.planning_tools is None
def test_praisonaiagents_passes_tools_to_planning_agent(self):
"""Test that planning_tools are passed to PlanningAgent."""
from praisonaiagents import Agent, Task, PraisonAIAgents
def search_tool(query: str) -> str:
return "results"
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_tools=[search_tool]
)
# Get the planning agent
planning_agent = agents._get_planning_agent()
assert planning_agent is not None
assert planning_agent.tools is not None
assert len(planning_agent.tools) == 1
# =============================================================================
# SECTION 5: PlanningAgent Reasoning Mode Tests
# =============================================================================
class TestPlanningAgentReasoningMode:
"""Tests for PlanningAgent reasoning mode."""
def test_planning_agent_accepts_reasoning_parameter(self):
"""Test that PlanningAgent accepts reasoning parameter."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent(reasoning=True)
assert planner.reasoning is True
def test_planning_agent_reasoning_default_false(self):
"""Test that reasoning defaults to False."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent()
assert planner.reasoning is False
def test_planning_agent_accepts_max_reasoning_steps(self):
"""Test that PlanningAgent accepts max_reasoning_steps parameter."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent(reasoning=True, max_reasoning_steps=10)
assert planner.max_reasoning_steps == 10
def test_planning_agent_max_reasoning_steps_default(self):
"""Test that max_reasoning_steps defaults to 5."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent(reasoning=True)
assert planner.max_reasoning_steps == 5
# =============================================================================
# SECTION 6: PraisonAIAgents planning_reasoning Parameter Tests
# =============================================================================
class TestPraisonAIAgentsPlanningReasoning:
"""Tests for PraisonAIAgents planning_reasoning parameter."""
def test_praisonaiagents_accepts_planning_reasoning(self):
"""Test that PraisonAIAgents accepts planning_reasoning parameter."""
from praisonaiagents import Agent, Task, PraisonAIAgents
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_reasoning=True
)
assert agents.planning_reasoning is True
def test_praisonaiagents_planning_reasoning_default_false(self):
"""Test that planning_reasoning defaults to False."""
from praisonaiagents import Agent, Task, PraisonAIAgents
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True
)
assert agents.planning_reasoning is False
def test_praisonaiagents_passes_reasoning_to_planning_agent(self):
"""Test that planning_reasoning is passed to PlanningAgent."""
from praisonaiagents import Agent, Task, PraisonAIAgents
agent = Agent(name="Test", role="Tester")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_reasoning=True
)
planning_agent = agents._get_planning_agent()
assert planning_agent.reasoning is True
# =============================================================================
# SECTION 7: Integration Tests
# =============================================================================
class TestPlanningToolsIntegration:
"""Integration tests for planning with tools."""
def test_planning_with_tools_and_reasoning(self):
"""Test planning with both tools and reasoning enabled."""
from praisonaiagents import Agent, Task, PraisonAIAgents
def search_tool(query: str) -> str:
return "search results"
agent = Agent(name="Researcher", role="Research Analyst")
task = Task(description="Research AI trends", agent=agent)
agents = PraisonAIAgents(
agents=[agent],
tasks=[task],
planning=True,
planning_tools=[search_tool],
planning_reasoning=True,
auto_approve_plan=True
)
assert agents.planning is True
assert agents.planning_tools is not None
assert agents.planning_reasoning is True
planning_agent = agents._get_planning_agent()
assert planning_agent.tools is not None
assert planning_agent.reasoning is True
# =============================================================================
# SECTION 8: RESEARCH_TOOLS List Tests
# =============================================================================
class TestResearchToolsList:
"""Tests for RESEARCH_TOOLS list in planning module."""
def test_research_tools_list_exists(self):
"""Test that RESEARCH_TOOLS list exists."""
from praisonaiagents.planning import RESEARCH_TOOLS
assert RESEARCH_TOOLS is not None
assert isinstance(RESEARCH_TOOLS, list)
def test_research_tools_contains_search_tools(self):
"""Test that RESEARCH_TOOLS contains common search tools."""
from praisonaiagents.planning import RESEARCH_TOOLS
expected_tools = [
"web_search",
"search_web",
"duckduckgo_search",
]
for tool in expected_tools:
assert tool in RESEARCH_TOOLS, f"{tool} should be in RESEARCH_TOOLS"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,142 @@
"""Test Tavily tools integration."""
import os
import sys
# Test 1: Import test - verify lazy loading works
print("=" * 60)
print("Test 1: Import Test (Lazy Loading)")
print("=" * 60)
try:
from praisonaiagents.tools import tavily_search, tavily_extract, tavily_crawl, tavily_map
print("✓ Successfully imported tavily_search, tavily_extract, tavily_crawl, tavily_map")
except ImportError as e:
print(f"✗ Import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import TavilyTools
print("✓ Successfully imported TavilyTools class")
except ImportError as e:
print(f"✗ TavilyTools import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import tavily_search_async, tavily_extract_async
print("✓ Successfully imported async functions")
except ImportError as e:
print(f"✗ Async import failed: {e}")
sys.exit(1)
# Test 2: Check API key detection
print("\n" + "=" * 60)
print("Test 2: API Key Detection")
print("=" * 60)
api_key = os.environ.get("TAVILY_API_KEY")
if api_key:
print(f"✓ TAVILY_API_KEY is set (length: {len(api_key)})")
else:
print("✗ TAVILY_API_KEY is not set")
print(" To run full tests, set: export TAVILY_API_KEY=your_api_key")
# Test 3: Error handling without API key
print("\n" + "=" * 60)
print("Test 3: Error Handling (No API Key)")
print("=" * 60)
# Temporarily unset API key to test error handling
original_key = os.environ.pop("TAVILY_API_KEY", None)
try:
result = tavily_search("test query")
if "error" in result:
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
else:
print("✗ Expected error but got results")
except Exception as e:
print(f"✗ Unexpected exception: {e}")
# Restore API key
if original_key:
os.environ["TAVILY_API_KEY"] = original_key
# Test 4: TavilyTools class instantiation
print("\n" + "=" * 60)
print("Test 4: TavilyTools Class")
print("=" * 60)
try:
tools = TavilyTools()
print("✓ TavilyTools instantiated successfully")
# Check methods exist
methods = ['search', 'extract', 'crawl', 'map', 'asearch', 'aextract']
for method in methods:
if hasattr(tools, method):
print(f" ✓ Method '{method}' exists")
else:
print(f" ✗ Method '{method}' missing")
except Exception as e:
print(f"✗ TavilyTools instantiation failed: {e}")
# Test 5: Full integration test (only if API key is available)
print("\n" + "=" * 60)
print("Test 5: Full Integration Test")
print("=" * 60)
if os.environ.get("TAVILY_API_KEY"):
print("Running search test...")
try:
result = tavily_search("Python programming", max_results=2)
if "error" not in result:
print(f"✓ Search successful!")
print(f" Query: {result.get('query', 'N/A')}")
print(f" Results: {len(result.get('results', []))} items")
if result.get('results'):
print(f" First result: {result['results'][0].get('title', 'N/A')[:50]}...")
else:
print(f"✗ Search returned error: {result['error']}")
except Exception as e:
print(f"✗ Search failed with exception: {e}")
else:
print("⊘ Skipped (TAVILY_API_KEY not set)")
# Test 6: Agent integration test
print("\n" + "=" * 60)
print("Test 6: Agent Integration Test")
print("=" * 60)
if os.environ.get("TAVILY_API_KEY") and os.environ.get("OPENAI_API_KEY"):
try:
from praisonaiagents import Agent
agent = Agent(
name="SearchAgent",
role="Web Researcher",
goal="Search the web for information",
tools=[tavily_search]
)
print("✓ Agent created with tavily_search tool")
# Check tool is properly registered
if agent.tools and tavily_search in agent.tools:
print("✓ tavily_search is in agent's tools list")
else:
print("⊘ Tool registration check inconclusive")
except Exception as e:
print(f"✗ Agent integration failed: {e}")
else:
missing = []
if not os.environ.get("TAVILY_API_KEY"):
missing.append("TAVILY_API_KEY")
if not os.environ.get("OPENAI_API_KEY"):
missing.append("OPENAI_API_KEY")
print(f"⊘ Skipped (missing: {', '.join(missing)})")
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
print("All import and basic tests passed!")
print("For full functionality, ensure TAVILY_API_KEY is set.")
@@ -0,0 +1,135 @@
"""Test You.com tools integration."""
import os
import sys
# Test 1: Import test - verify lazy loading works
print("=" * 60)
print("Test 1: Import Test (Lazy Loading)")
print("=" * 60)
try:
from praisonaiagents.tools import ydc_search, ydc_contents, ydc_news, ydc_images
print("✓ Successfully imported ydc_search, ydc_contents, ydc_news, ydc_images")
except ImportError as e:
print(f"✗ Import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import YouTools
print("✓ Successfully imported YouTools class")
except ImportError as e:
print(f"✗ YouTools import failed: {e}")
sys.exit(1)
# Test 2: Check API key detection
print("\n" + "=" * 60)
print("Test 2: API Key Detection")
print("=" * 60)
api_key = os.environ.get("YDC_API_KEY")
if api_key:
print(f"✓ YDC_API_KEY is set (length: {len(api_key)})")
else:
print("✗ YDC_API_KEY is not set")
print(" To run full tests, set: export YDC_API_KEY=your_api_key")
# Test 3: Error handling without API key
print("\n" + "=" * 60)
print("Test 3: Error Handling (No API Key)")
print("=" * 60)
# Temporarily unset API key to test error handling
original_key = os.environ.pop("YDC_API_KEY", None)
try:
result = ydc_search("test query")
if "error" in result:
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
else:
print("✗ Expected error but got results")
except Exception as e:
print(f"✗ Unexpected exception: {e}")
# Restore API key
if original_key:
os.environ["YDC_API_KEY"] = original_key
# Test 4: YouTools class instantiation
print("\n" + "=" * 60)
print("Test 4: YouTools Class")
print("=" * 60)
try:
tools = YouTools()
print("✓ YouTools instantiated successfully")
# Check methods exist
methods = ['search', 'get_contents', 'live_news', 'images']
for method in methods:
if hasattr(tools, method):
print(f" ✓ Method '{method}' exists")
else:
print(f" ✗ Method '{method}' missing")
except Exception as e:
print(f"✗ YouTools instantiation failed: {e}")
# Test 5: Full integration test (only if API key is available)
print("\n" + "=" * 60)
print("Test 5: Full Integration Test")
print("=" * 60)
if os.environ.get("YDC_API_KEY"):
print("Running search test...")
try:
result = ydc_search("Python programming", count=2)
if "error" not in result:
print(f"✓ Search successful!")
web_results = result.get('results', {}).get('web', [])
print(f" Web results: {len(web_results)} items")
if web_results:
print(f" First result: {web_results[0].get('title', 'N/A')[:50]}...")
else:
print(f"✗ Search returned error: {result['error']}")
except Exception as e:
print(f"✗ Search failed with exception: {e}")
else:
print("⊘ Skipped (YDC_API_KEY not set)")
# Test 6: Agent integration test
print("\n" + "=" * 60)
print("Test 6: Agent Integration Test")
print("=" * 60)
if os.environ.get("YDC_API_KEY") and os.environ.get("OPENAI_API_KEY"):
try:
from praisonaiagents import Agent
agent = Agent(
name="SearchAgent",
role="Web Researcher",
goal="Search the web for information",
tools=[ydc_search]
)
print("✓ Agent created with ydc_search tool")
# Check tool is properly registered
if agent.tools and ydc_search in agent.tools:
print("✓ ydc_search is in agent's tools list")
else:
print("⊘ Tool registration check inconclusive")
except Exception as e:
print(f"✗ Agent integration failed: {e}")
else:
missing = []
if not os.environ.get("YDC_API_KEY"):
missing.append("YDC_API_KEY")
if not os.environ.get("OPENAI_API_KEY"):
missing.append("OPENAI_API_KEY")
print(f"⊘ Skipped (missing: {', '.join(missing)})")
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
print("All import and basic tests passed!")
print("For full functionality, ensure YDC_API_KEY is set.")
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.175"
version = "0.0.188"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+285 -3
View File
@@ -58,6 +58,12 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
- 🌐 **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)
## Using Python Code
@@ -101,7 +107,34 @@ Run:
python app.py
```
### 3. Deep Research Agent
### 3. Agent with Planning Mode
Enable planning for any agent - the agent creates a plan, then executes step by step:
```python
from praisonaiagents import Agent
def search_web(query: str) -> str:
return f"Search results for: {query}"
agent = Agent(
name="AI Assistant",
instructions="Research and write about topics",
planning=True, # Enable planning mode
planning_tools=[search_web], # Tools for planning research
planning_reasoning=True # Chain-of-thought reasoning
)
result = agent.start("Research AI trends in 2025 and write a summary")
```
**What happens:**
1. 📋 Agent creates a multi-step plan
2. 🚀 Executes each step sequentially
3. 📊 Shows progress with context passing
4. ✅ Returns final result
### 4. Deep Research Agent
Automated research with real-time streaming, web search, and citations using OpenAI or Gemini Deep Research APIs.
@@ -139,7 +172,7 @@ print(result.report)
- 🛠️ Built-in tools: web search, code interpreter, MCP, file search
- 🔄 Automatic provider detection from model name
### 4. Query Rewriter Agent
### 5. Query Rewriter Agent
Transform user queries to improve RAG retrieval quality using multiple strategies.
@@ -174,6 +207,160 @@ result = agent.rewrite("What about cost?", chat_history=[...])
- **CONTEXTUAL**: Resolve references using conversation history
- **AUTO**: Automatically detect best strategy
### 6. Agent Memory (Zero Dependencies)
Enable persistent memory for agents - works out of the box without any extra packages.
```python
from praisonaiagents import Agent
from praisonaiagents.memory import FileMemory
# Enable memory with a single parameter
agent = Agent(
name="Personal Assistant",
instructions="You are a helpful assistant that remembers user preferences.",
memory=True, # Enables file-based memory (no extra deps!)
user_id="user123" # Isolate memory per user
)
# Memory is automatically injected into conversations
result = agent.start("My name is John and I prefer Python")
# Agent will remember this for future conversations
```
**Memory Types:**
- **Short-term**: Rolling buffer of recent context (auto-expires)
- **Long-term**: Persistent important facts (sorted by importance)
- **Entity**: People, places, organizations with attributes
- **Episodic**: Date-based interaction history
**Advanced Features:**
```python
from praisonaiagents.memory import FileMemory
memory = FileMemory(user_id="user123")
# Session Save/Resume
memory.save_session("project_session", conversation_history=[...])
memory.resume_session("project_session")
# Context Compression
memory.compress(llm_func=lambda p: agent.chat(p), max_items=10)
# Checkpointing
memory.create_checkpoint("before_refactor", include_files=["main.py"])
memory.restore_checkpoint("before_refactor", restore_files=True)
# Slash Commands
memory.handle_command("/memory show")
memory.handle_command("/memory save my_session")
```
**Storage Options:**
| Option | Dependencies | Description |
|--------|-------------|-------------|
| `memory=True` | None | File-based JSON storage (default) |
| `memory="file"` | None | Explicit file-based storage |
| `memory="sqlite"` | Built-in | SQLite with indexing |
| `memory="chromadb"` | chromadb | Vector/semantic search |
### 6. Rules & Instructions
PraisonAI auto-discovers instruction files from your project root and git root:
| File | Description | Priority |
|------|-------------|----------|
| `PRAISON.md` | PraisonAI native instructions | High |
| `PRAISON.local.md` | Local overrides (gitignored) | Higher |
| `CLAUDE.md` | Claude Code memory file | High |
| `CLAUDE.local.md` | Local overrides (gitignored) | Higher |
| `AGENTS.md` | OpenAI Codex CLI instructions | High |
| `GEMINI.md` | Gemini CLI memory file | High |
| `.cursorrules` | Cursor IDE rules | High |
| `.windsurfrules` | Windsurf IDE rules | High |
| `.claude/rules/*.md` | Claude Code modular rules | Medium |
| `.windsurf/rules/*.md` | Windsurf modular rules | Medium |
| `.cursor/rules/*.mdc` | Cursor modular rules | Medium |
| `.praison/rules/*.md` | Workspace rules | Medium |
| `~/.praison/rules/*.md` | Global rules | Low |
```python
from praisonaiagents import Agent
# Agent auto-discovers CLAUDE.md, AGENTS.md, GEMINI.md, etc.
agent = Agent(name="Assistant", instructions="You are helpful.")
# Rules are injected into system prompt automatically
```
**@Import Syntax:**
```markdown
# CLAUDE.md
See @README for project overview
See @docs/architecture.md for system design
@~/.praison/my-preferences.md
```
**Rule File Format (with YAML frontmatter):**
```markdown
---
description: Python coding guidelines
globs: ["**/*.py"]
activation: always # always, glob, manual, ai_decision
---
# Guidelines
- Use type hints
- Follow PEP 8
```
### 7. Auto-Generated Memories
```python
from praisonaiagents.memory import FileMemory, AutoMemory
memory = FileMemory(user_id="user123")
auto = AutoMemory(memory, enabled=True)
# Automatically extracts and stores memories from conversations
memories = auto.process_interaction(
"My name is John and I prefer Python for backend work"
)
# Extracts: name="John", preference="Python for backend"
```
### 8. Workflows
Create reusable multi-step workflows in `.praison/workflows/`:
```python
from praisonaiagents.memory import WorkflowManager
manager = WorkflowManager()
# Execute a workflow
result = manager.execute(
"deploy",
executor=lambda prompt: agent.chat(prompt),
variables={"environment": "production"}
)
```
### 9. Hooks
Configure in `.praison/hooks.json`:
```python
from praisonaiagents.memory import HooksManager
hooks = HooksManager()
# Register Python hooks
hooks.register("pre_write_code", lambda ctx: print(f"Writing {ctx['file']}"))
# Execute hooks
result = hooks.execute("pre_write_code", {"file": "main.py"})
```
## Using No Code
### Auto Mode:
@@ -228,6 +415,101 @@ praisonai research --query-rewrite --tools tools.py --save "Your research query"
praisonai research -v "Your research query"
```
### Planning Mode CLI:
```bash
# Enable planning mode - agent creates a plan before execution
praisonai "Research AI trends and write a summary" --planning
# Planning with tools for research
praisonai "Analyze market trends" --planning --planning-tools tools.py
# Planning with chain-of-thought reasoning
praisonai "Complex analysis task" --planning --planning-reasoning
# Auto-approve plans without confirmation
praisonai "Task" --planning --auto-approve-plan
```
### Memory CLI:
```bash
# Enable memory for agent (persists across sessions)
praisonai "My name is John" --memory
# Memory with user isolation
praisonai "Remember my preferences" --memory --user-id user123
# Memory management commands
praisonai memory show # Show memory statistics
praisonai memory add "User prefers Python" # Add to long-term memory
praisonai memory search "Python" # Search memories
praisonai memory clear # Clear short-term memory
praisonai memory clear all # Clear all memory
praisonai memory save my_session # Save session
praisonai memory resume my_session # Resume session
praisonai memory sessions # List saved sessions
praisonai memory checkpoint # Create checkpoint
praisonai memory restore <checkpoint_id> # Restore checkpoint
praisonai memory checkpoints # List checkpoints
praisonai memory help # Show all commands
```
### Rules CLI:
```bash
# List all loaded rules (from PRAISON.md, CLAUDE.md, etc.)
praisonai rules list
# Show specific rule details
praisonai rules show <rule_name>
# Create a new rule
praisonai rules create my_rule "Always use type hints"
# Delete a rule
praisonai rules delete my_rule
# Show rules statistics
praisonai rules stats
# Include manual rules with prompts
praisonai "Task" --include-rules security,testing
```
### Workflow CLI:
```bash
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
```
### Hooks CLI:
```bash
# List configured hooks
praisonai hooks list
# Show hooks statistics
praisonai hooks stats
# Create hooks.json template
praisonai hooks init
```
### Claude Memory Tool CLI:
```bash
# Enable Claude Memory Tool (Anthropic models only)
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
## Using JavaScript Code
```bash
@@ -859,7 +1141,7 @@ cd src/praisonai && uv publish
- 🎨 Interactive UIs
- 📄 YAML-based Configuration
- 🛠️ Custom Tool Integration
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
- 🔍 Internet Search Capability (Tavily, You.com, Exa, DuckDuckGo, Crawl4AI)
- 🖼️ Vision Language Model (VLM) Support
- 🎙️ Real-time Voice Interaction
+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.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.9.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+619 -1
View File
@@ -549,7 +549,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -588,6 +588,25 @@ class PraisonAI:
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
# Planning Mode arguments
parser.add_argument("--planning", action="store_true", help="Enable planning mode - create plan before execution")
parser.add_argument("--planning-tools", type=str, help="Tools for planning research (path to tools.py or comma-separated tool names)")
parser.add_argument("--planning-reasoning", action="store_true", help="Enable chain-of-thought reasoning in planning")
parser.add_argument("--auto-approve-plan", action="store_true", help="Auto-approve generated plans without user confirmation")
# Memory arguments
parser.add_argument("--memory", action="store_true", help="Enable file-based memory for agent")
parser.add_argument("--user-id", type=str, help="User ID for memory isolation")
# Rules arguments
parser.add_argument("--include-rules", type=str, help="Include manual rules by name (comma-separated)")
# Workflow arguments
parser.add_argument("--workflow-var", action="append", help="Workflow variable in key=value format (can be used multiple times)")
# Claude Memory Tool arguments
parser.add_argument("--claude-memory", action="store_true", help="Enable Claude Memory Tool (Anthropic models only)")
# If we're in a test environment, parse with empty args to avoid pytest interference
if in_test_env:
args, unknown_args = parser.parse_known_args([])
@@ -734,6 +753,60 @@ class PraisonAI:
self.handle_research_command(research_query, research_model, verbose, save, query_rewrite, tools_path, rewrite_tools)
sys.exit(0)
elif args.command == 'memory':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'help'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
user_id = getattr(args, 'user_id', None)
self.handle_memory_command(action, action_args, user_id)
sys.exit(0)
elif args.command == 'rules':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'list'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
self.handle_rules_command(action, action_args)
sys.exit(0)
elif args.command == 'workflow':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'list'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
workflow_vars = {}
if getattr(args, 'workflow_var', None):
for var in args.workflow_var:
if '=' in var:
key, value = var.split('=', 1)
workflow_vars[key] = value
self.handle_workflow_command(action, action_args, workflow_vars)
sys.exit(0)
elif args.command == 'hooks':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action from remaining args
action = unknown_args[0] if unknown_args else 'list'
self.handle_hooks_command(action)
sys.exit(0)
# Only check framework availability for agent-related operations
if not args.command and (args.init or args.auto or args.framework):
if not CREWAI_AVAILABLE and not AUTOGEN_AVAILABLE and not PRAISONAI_AVAILABLE:
@@ -936,6 +1009,520 @@ class PraisonAI:
verbose = getattr(self.args, 'verbose', False)
return self._expand_prompt(prompt, expand_tools, verbose)
def _load_tools(self, tools_path: str) -> list:
"""
Load tools from a file path or comma-separated tool names.
Args:
tools_path: Path to tools.py file or comma-separated tool names
Returns:
List of tool functions
"""
tools_list = []
if not tools_path:
return tools_list
if os.path.isfile(tools_path):
# Load from file
try:
import inspect
spec = importlib.util.spec_from_file_location("tools_module", tools_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for name, obj in inspect.getmembers(module):
if inspect.isfunction(obj) and not name.startswith('_'):
tools_list.append(obj)
if tools_list:
print(f"[cyan]Loaded {len(tools_list)} tools from {tools_path}[/cyan]")
except Exception as e:
print(f"[yellow]Warning: Failed to load tools from {tools_path}: {e}[/yellow]")
else:
# Treat as comma-separated tool names
try:
from praisonaiagents.tools import TOOL_MAPPINGS
import praisonaiagents.tools as tools_module
tool_names = [t.strip() for t in tools_path.split(',')]
for tool_name in tool_names:
if tool_name in TOOL_MAPPINGS:
try:
tool = getattr(tools_module, tool_name)
tools_list.append(tool)
except Exception as e:
print(f"[yellow]Warning: Failed to load tool '{tool_name}': {e}[/yellow]")
else:
print(f"[yellow]Warning: Unknown tool '{tool_name}'[/yellow]")
if tools_list:
print(f"[cyan]Loaded {len(tools_list)} built-in tools[/cyan]")
except ImportError:
print("[yellow]Warning: Could not import tools module[/yellow]")
return tools_list
def handle_memory_command(self, action: str, action_args: list, user_id: str = None):
"""
Handle memory subcommand actions.
Args:
action: The memory action (show, add, search, clear, save, resume, etc.)
action_args: Additional arguments for the action
user_id: User ID for memory isolation
"""
try:
from praisonaiagents.memory import FileMemory
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
memory = FileMemory(user_id=user_id or "default")
if action == 'show':
stats = memory.get_stats()
table = Table(title="Memory Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
table.add_row(str(key), str(value))
console.print(table)
# Show recent memories
print("\n[bold]Recent Short-term Memories:[/bold]")
short_term = memory.get_short_term(limit=5)
for i, item in enumerate(short_term, 1):
content = item.get('content', str(item))[:100]
print(f" {i}. {content}")
elif action == 'add':
if not action_args:
print("[red]ERROR: Content required. Usage: praisonai memory add \"Your memory content\"[/red]")
return
content = ' '.join(action_args)
memory.add_long_term(content, importance=0.8)
print(f"[green]✅ Added to long-term memory: {content[:50]}...[/green]")
elif action == 'search':
if not action_args:
print("[red]ERROR: Query required. Usage: praisonai memory search \"query\"[/red]")
return
query = ' '.join(action_args)
results = memory.search(query, limit=10)
print(f"[bold]Search results for '{query}':[/bold]")
for i, result in enumerate(results, 1):
content = result.get('content', str(result))[:100]
print(f" {i}. {content}")
elif action == 'clear':
target = action_args[0] if action_args else 'short'
if target == 'all':
memory.clear_all()
print("[green]✅ All memory cleared[/green]")
else:
memory.clear_short_term()
print("[green]✅ Short-term memory cleared[/green]")
elif action == 'save':
if not action_args:
print("[red]ERROR: Session name required. Usage: praisonai memory save <session_name>[/red]")
return
session_name = action_args[0]
memory.save_session(session_name)
print(f"[green]✅ Session saved: {session_name}[/green]")
elif action == 'resume':
if not action_args:
print("[red]ERROR: Session name required. Usage: praisonai memory resume <session_name>[/red]")
return
session_name = action_args[0]
memory.resume_session(session_name)
print(f"[green]✅ Session resumed: {session_name}[/green]")
elif action == 'sessions':
sessions = memory.list_sessions()
if sessions:
print("[bold]Saved Sessions:[/bold]")
for s in sessions:
print(f" - {s.get('name', 'Unknown')} (saved: {s.get('saved_at', 'Unknown')})")
else:
print("[yellow]No saved sessions found[/yellow]")
elif action == 'compress':
print("[cyan]Compressing short-term memory...[/cyan]")
# Note: compress requires an LLM function, so we'll just show stats
stats = memory.get_stats()
print(f"[green]Short-term items: {stats.get('short_term_count', 0)}[/green]")
print("[yellow]Note: Full compression requires an LLM. Use programmatically with memory.compress(llm_func=...)[/yellow]")
elif action == 'checkpoint':
name = action_args[0] if action_args else None
checkpoint_id = memory.create_checkpoint(name)
print(f"[green]✅ Checkpoint created: {checkpoint_id}[/green]")
elif action == 'restore':
if not action_args:
print("[red]ERROR: Checkpoint ID required. Usage: praisonai memory restore <checkpoint_id>[/red]")
return
checkpoint_id = action_args[0]
memory.restore_checkpoint(checkpoint_id)
print(f"[green]✅ Checkpoint restored: {checkpoint_id}[/green]")
elif action == 'checkpoints':
checkpoints = memory.list_checkpoints()
if checkpoints:
print("[bold]Checkpoints:[/bold]")
for cp in checkpoints:
print(f" - {cp.get('id', 'Unknown')} ({cp.get('name', 'Unnamed')})")
else:
print("[yellow]No checkpoints found[/yellow]")
elif action == 'help' or action == '--help':
print("[bold]Memory Commands:[/bold]")
print(" praisonai memory show - Show memory statistics")
print(" praisonai memory add <content> - Add to long-term memory")
print(" praisonai memory search <query> - Search memories")
print(" praisonai memory clear [short|all] - Clear memory")
print(" praisonai memory save <session_name> - Save session")
print(" praisonai memory resume <session_name> - Resume session")
print(" praisonai memory sessions - List saved sessions")
print(" praisonai memory compress - Compress short-term memory")
print(" praisonai memory checkpoint [name] - Create checkpoint")
print(" praisonai memory restore <checkpoint_id> - Restore checkpoint")
print(" praisonai memory checkpoints - List checkpoints")
print("\n[bold]Options:[/bold]")
print(" --user-id <id> - User ID for memory isolation")
else:
print(f"[red]Unknown memory action: {action}[/red]")
print("Use 'praisonai memory help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import memory module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Memory command failed: {e}[/red]")
def handle_rules_command(self, action: str, action_args: list):
"""
Handle rules subcommand actions.
Args:
action: The rules action (list, show, create, delete, stats)
action_args: Additional arguments for the action
"""
try:
from praisonaiagents.memory import RulesManager
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
rules = RulesManager(workspace_path=os.getcwd())
if action == 'list':
all_rules = rules.get_all_rules()
if all_rules:
table = Table(title="Loaded Rules")
table.add_column("Name", style="cyan")
table.add_column("Description", style="white")
table.add_column("Activation", style="green")
table.add_column("Priority", style="yellow")
for rule in all_rules:
table.add_row(
rule.name,
rule.description[:50] + "..." if len(rule.description) > 50 else rule.description,
rule.activation,
str(rule.priority)
)
console.print(table)
else:
print("[yellow]No rules found. Create PRAISON.md, CLAUDE.md, or files in .praison/rules/[/yellow]")
elif action == 'show':
if not action_args:
print("[red]ERROR: Rule name required. Usage: praisonai rules show <name>[/red]")
return
rule_name = action_args[0]
rule = rules.get_rule_by_name(rule_name)
if rule:
print(f"[bold cyan]Rule: {rule.name}[/bold cyan]")
print(f"[bold]Description:[/bold] {rule.description}")
print(f"[bold]Activation:[/bold] {rule.activation}")
print(f"[bold]Priority:[/bold] {rule.priority}")
if rule.globs:
print(f"[bold]Globs:[/bold] {', '.join(rule.globs)}")
print(f"\n[bold]Content:[/bold]\n{rule.content}")
else:
print(f"[red]Rule not found: {rule_name}[/red]")
elif action == 'create':
if len(action_args) < 2:
print("[red]ERROR: Name and content required. Usage: praisonai rules create <name> <content>[/red]")
return
rule_name = action_args[0]
content = ' '.join(action_args[1:])
rules.create_rule(
name=rule_name,
content=content,
description=f"Rule created via CLI: {rule_name}",
activation="always",
scope="workspace"
)
print(f"[green]✅ Rule created: {rule_name}[/green]")
elif action == 'delete':
if not action_args:
print("[red]ERROR: Rule name required. Usage: praisonai rules delete <name>[/red]")
return
rule_name = action_args[0]
rules.delete_rule(rule_name)
print(f"[green]✅ Rule deleted: {rule_name}[/green]")
elif action == 'stats':
stats = rules.get_stats()
table = Table(title="Rules Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
if isinstance(value, dict):
table.add_row(str(key), str(value))
else:
table.add_row(str(key), str(value))
console.print(table)
elif action == 'help' or action == '--help':
print("[bold]Rules Commands:[/bold]")
print(" praisonai rules list - List all loaded rules")
print(" praisonai rules show <name> - Show specific rule details")
print(" praisonai rules create <name> <content> - Create a new rule")
print(" praisonai rules delete <name> - Delete a rule")
print(" praisonai rules stats - Show rules statistics")
print("\n[bold]Supported Rule Files:[/bold]")
print(" PRAISON.md, CLAUDE.md, AGENTS.md, GEMINI.md")
print(" .cursorrules, .windsurfrules")
print(" .praison/rules/*.md, ~/.praison/rules/*.md")
else:
print(f"[red]Unknown rules action: {action}[/red]")
print("Use 'praisonai rules help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import rules module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Rules command failed: {e}[/red]")
def handle_workflow_command(self, action: str, action_args: list, variables: dict = None):
"""
Handle workflow subcommand actions.
Args:
action: The workflow action (list, run, create, show)
action_args: Additional arguments for the action
variables: Workflow variables for substitution
"""
try:
from praisonaiagents.memory import WorkflowManager
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
manager = WorkflowManager(workspace_path=os.getcwd())
if action == 'list':
workflows = manager.list_workflows()
if workflows:
table = Table(title="Available Workflows")
table.add_column("Name", style="cyan")
table.add_column("Description", style="white")
table.add_column("Steps", style="green")
for workflow in workflows:
table.add_row(
workflow.name,
workflow.description[:50] + "..." if len(workflow.description) > 50 else workflow.description,
str(len(workflow.steps))
)
console.print(table)
else:
print("[yellow]No workflows found. Create files in .praison/workflows/[/yellow]")
elif action == 'run':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow run <name>[/red]")
return
workflow_name = action_args[0]
# Create a simple executor using PraisonAgent
def executor(prompt):
agent = PraisonAgent(
name="WorkflowExecutor",
role="Task Executor",
goal="Execute workflow steps"
)
return agent.chat(prompt)
print(f"[bold cyan]Running workflow: {workflow_name}[/bold cyan]")
result = manager.execute(
workflow_name,
executor=executor,
variables=variables or {}
)
if result.get("success"):
print("[green]✅ Workflow completed successfully![/green]")
for step_result in result.get("results", []):
status = "✅" if step_result.get("status") == "completed" else "❌"
print(f" {status} {step_result.get('step', 'Unknown step')}")
else:
print(f"[red]❌ Workflow failed: {result.get('error', 'Unknown error')}[/red]")
elif action == 'show':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow show <name>[/red]")
return
workflow_name = action_args[0]
workflow = manager.get_workflow(workflow_name)
if workflow:
print(f"[bold cyan]Workflow: {workflow.name}[/bold cyan]")
print(f"[bold]Description:[/bold] {workflow.description}")
print(f"\n[bold]Steps ({len(workflow.steps)}):[/bold]")
for i, step in enumerate(workflow.steps, 1):
print(f" {i}. {step.name}: {step.action[:80]}...")
if workflow.variables:
print(f"\n[bold]Variables:[/bold] {workflow.variables}")
else:
print(f"[red]Workflow not found: {workflow_name}[/red]")
elif action == 'create':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow create <name>[/red]")
return
workflow_name = action_args[0]
# Create a simple template workflow
manager.create_workflow(
name=workflow_name,
description=f"Workflow created via CLI: {workflow_name}",
steps=[
{"name": "Step 1", "action": "First step - edit this in .praison/workflows/"},
{"name": "Step 2", "action": "Second step - edit this in .praison/workflows/"}
]
)
print(f"[green]✅ Workflow created: {workflow_name}[/green]")
print(f"[cyan]Edit the workflow in .praison/workflows/{workflow_name}.md[/cyan]")
elif action == 'help' or action == '--help':
print("[bold]Workflow Commands:[/bold]")
print(" praisonai workflow list - List available workflows")
print(" praisonai workflow run <name> - Execute a workflow")
print(" praisonai workflow show <name> - Show workflow details")
print(" praisonai workflow create <name> - Create a new workflow")
print("\n[bold]Options:[/bold]")
print(" --workflow-var key=value - Set workflow variable (can be repeated)")
print("\n[bold]Example:[/bold]")
print(" praisonai workflow run deploy --workflow-var environment=staging")
else:
print(f"[red]Unknown workflow action: {action}[/red]")
print("Use 'praisonai workflow help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import workflow module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Workflow command failed: {e}[/red]")
def handle_hooks_command(self, action: str):
"""
Handle hooks subcommand actions.
Args:
action: The hooks action (list, stats, init)
"""
try:
from praisonaiagents.memory import HooksManager
from rich import print
from rich.table import Table
from rich.console import Console
import json
console = Console()
hooks = HooksManager(workspace_path=os.getcwd())
if action == 'list':
stats = hooks.get_stats()
if stats.get('total_hooks', 0) > 0:
print("[bold]Configured Hooks:[/bold]")
for event in stats.get('events', []):
print(f" - {event}")
else:
print("[yellow]No hooks configured. Create .praison/hooks.json[/yellow]")
elif action == 'stats':
stats = hooks.get_stats()
table = Table(title="Hooks Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
table.add_row(str(key), str(value))
console.print(table)
elif action == 'init':
hooks_dir = os.path.join(os.getcwd(), ".praison")
os.makedirs(hooks_dir, exist_ok=True)
hooks_file = os.path.join(hooks_dir, "hooks.json")
if os.path.exists(hooks_file):
print(f"[yellow]hooks.json already exists at {hooks_file}[/yellow]")
else:
template = {
"enabled": True,
"timeout": 30,
"hooks": {
"pre_write_code": "./scripts/lint.sh",
"post_write_code": "./scripts/format.sh",
"pre_run_command": {
"command": "./scripts/validate.sh",
"timeout": 60,
"block_on_failure": True
}
}
}
with open(hooks_file, 'w') as f:
json.dump(template, f, indent=2)
print(f"[green]✅ Created hooks.json at {hooks_file}[/green]")
print("[cyan]Edit the file to configure your hooks[/cyan]")
elif action == 'help' or action == '--help':
print("[bold]Hooks Commands:[/bold]")
print(" praisonai hooks list - List configured hooks")
print(" praisonai hooks stats - Show hooks statistics")
print(" praisonai hooks init - Create hooks.json template")
print("\n[bold]Hook Events:[/bold]")
print(" pre_read_code, post_read_code")
print(" pre_write_code, post_write_code")
print(" pre_run_command, post_run_command")
print(" pre_user_prompt, post_user_prompt")
print(" pre_mcp_tool_use, post_mcp_tool_use")
else:
print(f"[red]Unknown hooks action: {action}[/red]")
print("Use 'praisonai hooks help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import hooks module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Hooks command failed: {e}[/red]")
def _save_output(self, prompt: str, result: str):
"""
Save output to output/prompts/ folder.
@@ -994,6 +1581,37 @@ class PraisonAI:
agent_config["web_fetch"] = True
if getattr(self.args, 'prompt_caching', False):
agent_config["prompt_caching"] = True
# Planning Mode
if getattr(self.args, 'planning', False):
agent_config["planning"] = True
print("[bold cyan]Planning mode enabled - agent will create a plan before execution[/bold cyan]")
# Load planning tools if specified
if getattr(self.args, 'planning_tools', None):
planning_tools_list = self._load_tools(self.args.planning_tools)
if planning_tools_list:
agent_config["planning_tools"] = planning_tools_list
if getattr(self.args, 'planning_reasoning', False):
agent_config["planning_reasoning"] = True
# Memory
if getattr(self.args, 'memory', False):
agent_config["memory"] = True
print("[bold cyan]Memory enabled - agent will remember context across sessions[/bold cyan]")
if getattr(self.args, 'user_id', None):
agent_config["user_id"] = self.args.user_id
# Claude Memory Tool (Anthropic only)
if getattr(self.args, 'claude_memory', False):
llm = getattr(self.args, 'llm', '')
if llm and 'anthropic' in llm.lower():
agent_config["claude_memory"] = True
print("[bold cyan]Claude Memory Tool enabled - Claude will autonomously manage memories[/bold cyan]")
else:
print("[yellow]Warning: --claude-memory requires an Anthropic model (--llm anthropic/...)[/yellow]")
agent = PraisonAgent(**agent_config)
result = agent.start(prompt)
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.3.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.3.9 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.3.2"
__version__ = "2.3.9"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.0.175",
"praisonaiagents>=0.0.188",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+1 -1
View File
@@ -365,7 +365,7 @@ This is **normal and expected** during development. The key metrics are:
2. **Mocking**: Mock external dependencies and APIs
3. **Naming**: Clear, descriptive test names
4. **Documentation**: Document complex test scenarios
5. **Performance**: Keep unit tests fast (<1s each)
5. **Performance**: Keep unit tests fast (under 1s each)
6. **Coverage**: Aim for high coverage of critical paths
7. **Maintainability**: Regular test maintenance and updates
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Tests for new CLI features (fast, direct import, no subprocess):
- Planning Mode
- Memory CLI
- Rules CLI
- Workflow CLI
- Hooks CLI
- Claude Memory Tool
"""
import argparse
class TestCLIArgumentParsing:
"""Test CLI argument parsing directly (fast, no subprocess)."""
def get_parser(self):
"""Create argument parser matching CLI."""
parser = argparse.ArgumentParser(description="praisonAI CLI")
# Planning args
parser.add_argument("--planning", action="store_true")
parser.add_argument("--planning-tools")
parser.add_argument("--planning-reasoning", action="store_true")
parser.add_argument("--auto-approve-plan", action="store_true")
# Memory args
parser.add_argument("--memory", action="store_true")
parser.add_argument("--user-id")
# Rules args
parser.add_argument("--include-rules")
# Workflow args
parser.add_argument("--workflow-var", action="append")
# Claude memory
parser.add_argument("--claude-memory", action="store_true")
parser.add_argument("command", nargs="?")
return parser
def test_planning_args_parsed(self):
"""Test planning arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--planning-reasoning", "test"])
assert args.planning is True
assert args.planning_reasoning is True
assert args.command == "test"
def test_planning_tools_parsed(self):
"""Test planning-tools argument is parsed."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--planning-tools", "tools.py", "test"])
assert args.planning is True
assert args.planning_tools == "tools.py"
def test_memory_args_parsed(self):
"""Test memory arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--memory", "--user-id", "user123", "test"])
assert args.memory is True
assert args.user_id == "user123"
def test_rules_args_parsed(self):
"""Test rules arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--include-rules", "security,testing", "test"])
assert args.include_rules == "security,testing"
def test_workflow_vars_parsed(self):
"""Test workflow-var arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args([
"--workflow-var", "env=prod",
"--workflow-var", "branch=main",
"test"
])
assert args.workflow_var == ["env=prod", "branch=main"]
def test_claude_memory_parsed(self):
"""Test claude-memory argument is parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--claude-memory", "test"])
assert args.claude_memory is True
def test_auto_approve_plan_parsed(self):
"""Test auto-approve-plan argument is parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--auto-approve-plan", "test"])
assert args.auto_approve_plan is True
class TestMemoryModule:
"""Test FileMemory module directly."""
def test_file_memory_import(self):
"""Test FileMemory can be imported."""
try:
from praisonaiagents.memory import FileMemory
assert FileMemory is not None
except ImportError:
pass # Skip if not installed
def test_file_memory_basic(self, tmp_path):
"""Test FileMemory basic operations."""
try:
from praisonaiagents.memory import FileMemory
memory = FileMemory(user_id="test_user")
# Test add
memory.add_long_term("Test fact")
# Test stats
stats = memory.get_stats()
assert stats["user_id"] == "test_user"
assert stats["long_term_count"] >= 1
except (ImportError, TypeError):
pass # Skip if not installed or API changed
class TestRulesModule:
"""Test RulesManager module directly."""
def test_rules_manager_import(self):
"""Test RulesManager can be imported."""
try:
from praisonaiagents.memory import RulesManager
assert RulesManager is not None
except ImportError:
pass # Skip if not installed
def test_rules_manager_basic(self):
"""Test RulesManager basic operations."""
try:
from praisonaiagents.memory import RulesManager
manager = RulesManager()
# Test stats
stats = manager.get_stats()
assert "total_rules" in stats
except ImportError:
pass # Skip if not installed
class TestWorkflowModule:
"""Test WorkflowManager module directly."""
def test_workflow_manager_import(self):
"""Test WorkflowManager can be imported."""
try:
from praisonaiagents.memory import WorkflowManager
assert WorkflowManager is not None
except ImportError:
pass # Skip if not installed
class TestHooksModule:
"""Test HooksManager module directly."""
def test_hooks_manager_import(self):
"""Test HooksManager can be imported."""
try:
from praisonaiagents.memory import HooksManager
assert HooksManager is not None
except ImportError:
pass # Skip if not installed
def test_hooks_manager_basic(self):
"""Test HooksManager basic operations."""
try:
from praisonaiagents.memory import HooksManager
hooks = HooksManager()
# Test stats
stats = hooks.get_stats()
assert "total_hooks" in stats
except ImportError:
pass # Skip if not installed
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
+4 -4
View File
@@ -4197,7 +4197,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.22" },
{ name = "praisonaiagents", specifier = ">=0.0.175" },
{ name = "praisonaiagents", specifier = ">=0.0.188" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4252,7 +4252,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.175"
version = "0.0.188"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4261,9 +4261,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/3a/a3f78a53c5ffdab876b59c7f7ea86ed815d641dff45465c70b6fd8ab1a8f/praisonaiagents-0.0.175.tar.gz", hash = "sha256:5f2bdeae4988a34947aa231c7a3de76bf50d60d2dfd43e214769d188886d6362", size = 332558, upload-time = "2025-12-15T12:43:06.491Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/b1/4ea337476bdb07ef504af6023e378c513a9c35414ff080a777221e70add5/praisonaiagents-0.0.188.tar.gz", hash = "sha256:ba498e6328e3a6184e3d99d2ae7a73473bd0aef747abe71e9a2c99d201e791f5", size = 438151, upload-time = "2025-12-16T08:17:22.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/c9/93cf31b2c1011ee205efc75080a4d7ab9ed68e53e033d51a861112545428/praisonaiagents-0.0.175-py3-none-any.whl", hash = "sha256:1bdb672efefd562c47be481008551894e57083e52005227650f97999f88e7ff6", size = 339912, upload-time = "2025-12-15T12:43:05.064Z" },
{ url = "https://files.pythonhosted.org/packages/0d/3c/122ca9485b531a7307a44c1149f7b6e72b5a962b2244f8a8756f459f50e8/praisonaiagents-0.0.188-py3-none-any.whl", hash = "sha256:3ba651ca4e52de2b2f63101f265411a676f918ac308d4dbb95f2124cdc42ca6e", size = 420694, upload-time = "2025-12-16T08:17:20.78Z" },
]
[[package]]